Files
member-console/internal/server/server.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

835 lines
36 KiB
Go

package server
import (
"context"
"database/sql"
"fmt"
"html/template"
"io/fs"
"log/slog"
"net"
"net/http"
"net/url"
"strings"
"time"
"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/domains"
"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/fulfillment"
"git.coopcloud.tech/wiki-cafe/member-console/internal/identity"
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/gorilla/csrf"
"github.com/rs/cors"
"github.com/spf13/viper"
"go.temporal.io/sdk/client"
)
// Config holds the configuration for the server.
type Config struct {
Port string
Env string
CSRFSecret string
Logger *slog.Logger
Database *sql.DB // Raw DB connection
IdentityQ identity.Querier // Identity module queries
OrgQ organization.Querier // Organization module queries
EntitlementsQ entitlements.Querier // Entitlements module queries
BillingQ billing.Querier // Billing module queries
StripeQ stripedb.Querier // Stripe module queries
DomainsQ domains.Querier // Domains registry queries
TemporalClient client.Client // nil if not configured
StripeWebhookSecret string // Stripe webhook signing secret
StripeAPIKey string // Stripe API secret key
StripeDashboardURL string // Stripe dashboard base URL for deep links
BaseURL string // Application base URL for redirects
// AskFallbackURL is the optional legacy on-demand-TLS answerer
// (domains-ask-fallback-url; empty disables it). Registry misses — and
// only misses — are forwarded there, so a deployment can move off a
// filesystem-based answerer incrementally (design.md D5).
AskFallbackURL string
// DomainsPolicy is the deployment's claim lifecycle policy (the
// domains-* core keys). Its zero value is the shipped defaults, so a
// deployment that configures nothing allocates exactly as the registry
// constants say (internal/domains.Policy).
DomainsPolicy domains.Policy
// DomainsConnectTarget is the DNS target members point external custom
// domains at (domains-connect-target core key), resolved once by the
// composition root. Empty disables external domain claims deployment-wide
// (design D7's two-part gate).
DomainsConnectTarget string
// RouteMounts registers each installed integration's HTTP routes (see
// internal/integrations.RouteProvider). Populated by the composition
// root (cmd/start.go) from the integration registry; core route
// construction (below) never names an integration handler type.
RouteMounts []RouteMount
// UIMounts registers each installed integration's templates and/or
// static assets (see internal/integrations.UIProvider). Populated by
// the composition root (cmd/start.go) from the integration registry.
UIMounts []UIMount
// DashboardCards lists each installed integration's member dashboard
// cards (see DashboardCardProvider). Populated by the composition root
// (cmd/start.go) from the integration registry, in registry order;
// index.html renders one generic card shell per entry and core never
// names an integration in the dashboard.
DashboardCards []DashboardCard
// IntegrationConfigs lists each installed integration's declared
// configuration (internal/config.ConfigProvider) for the operator
// settings surface. Populated by the composition root (cmd/start.go)
// from the integration registry — slug, display name, and declared
// keys only, so core renders every integration's settings page from
// one handler/template pair and never imports an adapter.
IntegrationConfigs []IntegrationConfigInfo
}
// IntegrationConfigInfo is one installed integration's declared
// configuration, as the composition root hands it to the operator
// settings surface.
type IntegrationConfigInfo struct {
Slug string
DisplayName string
// SurfacePath is the integration's operator admin page (manifest
// OperatorSurfacePath; "" when none) so the settings page can link
// back to it.
SurfacePath string
Keys []config.ConfigKey
}
// Deps exposes the generic dependencies a registered integration's route
// hook needs to construct its own handlers: the raw DB connection, core
// module queriers, the Temporal client (nil if Temporal isn't configured),
// the auth-setup result (session management, CSRF-aware middleware, role
// checks), and the logger. Integration-specific configuration (FedWiki's
// farm API URL, Stripe's webhook secret, ...) is deliberately not part of
// Deps — each integration reads its own configuration directly for now
// (ConfigProvider-driven binding is a later step), so this struct never
// grows a field per integration.
type Deps struct {
Database *sql.DB
IdentityQ identity.Querier
OrgQ organization.Querier
EntitlementsQ entitlements.Querier
BillingQ billing.Querier
TemporalClient client.Client
AuthConfig *auth.Config
Logger *slog.Logger
// DomainsPolicy is core configuration, not any integration's: an
// integration that builds its own domains.Registry (to claim names for
// its resources) must allocate under the SAME policy core does, and a
// registry built without it silently falls back to the shipped defaults
// instead of the deployment's.
DomainsPolicy domains.Policy
// DomainsConnectTarget is core configuration for the same reason as
// DomainsPolicy: an integration offering external-domain entry builds its
// gate from the SAME resolved value core does, never from a config read
// of its own (integration-config-parity).
DomainsConnectTarget string
}
// RouteMount is one integration's contribution to the HTTP server: Register
// constructs and registers its routes against the shared mux once Deps are
// available, and CSRFExemptPaths lists any request paths that must bypass
// CSRF protection (e.g. a webhook authenticated by the provider's own
// signature instead of a CSRF token). Produced from integrations
// implementing RouteProvider (below); Start consumes the slice without
// naming any integration handler type, and CSRF exemptions are declared by
// the route instead of hardcoded in Start.
type RouteMount struct {
Register func(mux *http.ServeMux, deps Deps) error
CSRFExemptPaths []string
}
// UIMount is one installed integration's contribution of templates and/or
// static assets, gathered from integrations implementing
// internal/integrations.UIProvider (either field may be nil for an
// integration that ships only one of the two, or neither — a UIMount is
// only produced when the type assertion against UIProvider succeeds, but
// an implementation is free to return a nil fs.FS from either method).
//
// Templates is parsed into the shared core template set built in Start,
// under the slug-prefix namespacing rule (design.md Decision 8): every
// template name introduced from Templates must be prefixed with Slug, or
// Start fails fast (panics) at boot — this is a startup-time contract
// check on the integration's own asset naming, not a runtime condition,
// so it is deliberately not a recoverable error.
//
// Static is mounted under the existing /static/ route at the per-slug
// subpath /static/<Slug>/ — same-origin, no new route surface, no CSP
// change.
type UIMount struct {
Slug string
Templates fs.FS
Static fs.FS
}
// composeUITemplates parses each mount's Templates FS into base and returns
// the extended template set. html/template names a parsed file by its base
// filename absent an explicit {{define}}, so — mirroring the slug-prefix
// rule already followed by the existing fedwiki_*.html templates — every
// "*.html" file a mount contributes must have a filename prefixed with the
// mount's Slug (design.md Decision 8). A violation, or any template parse
// error, panics rather than returning an error: this is a static
// contract-conformance check on the integration's own asset naming — a
// startup-time bug, never a runtime condition — and mirrors the
// template.Must convention Start already uses for the core template set
// this function extends. A mount with a nil or empty Templates FS is a
// no-op.
func composeUITemplates(base *template.Template, mounts []UIMount) *template.Template {
for _, mount := range mounts {
if mount.Templates == nil {
continue
}
names, err := fs.Glob(mount.Templates, "*.html")
if err != nil {
panic(fmt.Sprintf("integration %q: listing UI templates: %v", mount.Slug, err))
}
for _, name := range names {
if !strings.HasPrefix(name, mount.Slug) {
panic(fmt.Sprintf(
"integration %q registered template %q without the required %q slug prefix",
mount.Slug, name, mount.Slug))
}
}
if len(names) == 0 {
continue
}
base = template.Must(base.ParseFS(mount.Templates, "*.html"))
}
return base
}
// RouteProvider is implemented by installed integrations that expose HTTP
// routes; the composition root (cmd/start.go) type-asserts each
// integration from the registry (internal/integrations.All) against this
// interface and turns a match into a RouteMount. It is declared here
// rather than in internal/integrations deliberately: internal/migrate
// imports internal/integrations, and internal/server's own DB-backed
// tests import internal/migrate, so internal/integrations must not import
// internal/server (see that package's doc comment) — the interface lives
// at the point of use instead.
//
// RegisterRoutes constructs the integration's handlers from deps (no core
// code names an integration handler type) and registers them on mux.
// CSRFExemptPaths lists request paths that must bypass CSRF protection
// (e.g. a webhook endpoint authenticated by provider signature instead) —
// the integration declares the exemption itself rather than core
// hardcoding the path.
type RouteProvider interface {
RegisterRoutes(mux *http.ServeMux, deps Deps) error
CSRFExemptPaths() []string
}
// DashboardCard is one integration's declared card on the member dashboard.
// The declaration is a shell, not markup: core renders a generic Bootstrap
// card (header from Title, body self-loading via HTMX from PartialPath) and
// everything inside the body stays integration-owned and HTMX-delivered —
// core never renders integration-owned templates inline into the dashboard
// page (no template-set coupling; see the dashboard-display-genericity
// design doc). Modal markup a card needs must arrive inside its partial
// (use hx-preserve so an open modal survives a refresh re-render).
type DashboardCard struct {
// Title is the card-header text.
Title string
// PartialPath is the integration-owned HTMX route the card body loads
// from on page load.
PartialPath string
// RefreshEvent optionally names an event (triggered on <body>, e.g. via
// an HX-Trigger response header) that re-loads the card body.
RefreshEvent string
// Scripts lists page-level script URLs under the integration's
// /static/<slug>/ mount, rendered as <script defer src> tags in the
// page head — never inline (CSP), never inside swapped fragments
// (execution-order dependent).
Scripts []string
}
// Trigger returns the card body's hx-trigger specification: "load", plus
// the refresh event scoped to <body> when one is declared.
func (c DashboardCard) Trigger() string {
if c.RefreshEvent == "" {
return "load"
}
return "load, " + c.RefreshEvent + " from:body"
}
// DashboardCardProvider is implemented by installed integrations that
// contribute member dashboard cards. Like RouteProvider above, it is
// declared at the point of use rather than in internal/integrations — the
// same import-cycle rationale applies — and the composition root
// (cmd/start.go) type-asserts each registered integration against it,
// collecting declarations into Config.DashboardCards in registry order.
// An integration without member-facing UI simply doesn't implement this.
// PendingDomainClaim is one in-flight domain verification surfaced by the
// dashboard's pending notice (dissolve-member-domains): just enough to name
// the root and open its DNS instructions via the core claim-status partial.
type PendingDomainClaim struct {
ClaimID string
Root string
}
type DashboardCardProvider interface {
DashboardCards() []DashboardCard
}
// newRouteAwareHandler builds the top-level request dispatcher implementing
// design.md Decision 4 (spec: error-pages): mux's own registered patterns
// decide whether a request ever reaches authMiddleware at all. Go's
// ServeMux always resolves a path matching no more specific pattern to the
// least-specific one registered — the bare "/" catch-all every deployment
// registers for the dashboard — so a real match returns some OTHER pattern,
// or "/" itself only for the literal root path. Anything else means no
// route recognizes this path, so the styled 404 renders directly: no
// session required, and authMiddleware never runs, so an anonymous visitor
// to a stale or mistyped URL is diagnosed as "not found" rather than
// misread as "signed out" and bounced to the identity provider. A path that
// DOES match a real route keeps the ordinary auth-wrapped dispatch,
// including the login redirect when the session is absent — that behavior
// is unchanged, just moved one layer out.
//
// Extracted as its own function (rather than inlined in Start) so it is
// unit-testable against a small fake mux and a fake auth middleware,
// without standing up the real server's database and session dependencies.
func newRouteAwareHandler(mux *http.ServeMux, authMiddleware func(http.Handler) http.Handler, tmpl *SafeTemplates) http.Handler {
authWrapped := authMiddleware(mux)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, pattern := mux.Handler(r); pattern == "" || (pattern == "/" && r.URL.Path != "/") {
tmpl.RenderErrorPage(w, r, http.StatusNotFound, "The page you requested does not exist.")
return
}
authWrapped.ServeHTTP(w, r)
})
}
// Start initializes and starts the HTTP server.
func Start(ctx context.Context, cfg Config) error {
// The registry answers /domains/ask below, on the TLS handshake path of
// every proxy in front of the deployment. A missing querier would mean
// certificate issuance silently stops, so it is a required dependency
// rather than a conditional feature.
if cfg.DomainsQ == nil {
cfg.Logger.Error("domains registry queries are required to serve /domains/ask")
return fmt.Errorf("server: Config.DomainsQ is required")
}
// Create a new HTTP request router
httpRequestRouter := http.NewServeMux()
// Set up authentication with identity and organization query interfaces.
authConfig, err := auth.Setup(cfg.Database, cfg.IdentityQ, cfg.OrgQ)
if err != nil {
cfg.Logger.Error("failed to set up authentication", slog.Any("error", err))
return err
}
// Register auth handlers
authConfig.RegisterHandlers(httpRequestRouter)
// Register each installed integration's HTTP routes (FedWiki, the
// Stripe webhook, ...) via cfg.RouteMounts — see the Deps/RouteMount
// doc comments above. Core never constructs an integration handler
// directly; CSRF exemptions are collected from each mount below
// instead of being hardcoded in the CSRF config.
deps := Deps{
Database: cfg.Database,
IdentityQ: cfg.IdentityQ,
OrgQ: cfg.OrgQ,
EntitlementsQ: cfg.EntitlementsQ,
BillingQ: cfg.BillingQ,
TemporalClient: cfg.TemporalClient,
AuthConfig: authConfig,
Logger: cfg.Logger,
DomainsPolicy: cfg.DomainsPolicy,
DomainsConnectTarget: cfg.DomainsConnectTarget,
}
// The serving-authorization endpoint is core's route, not a provider's
// (spec: domain-authorization; design.md D5): it answers from the
// domains registry, so no integration is involved in the lookup. Core
// therefore seeds its own exemption here — the slice was previously
// assembled only from integration mounts, and it flows to BOTH
// csrfConfig.Ignore and authConfig.Middleware below. /domains/ask is
// unauthenticated by design: the TLS-terminating proxy that calls it
// during a handshake holds no session and no CSRF token.
// The handler itself is registered further below (after safeTmpl exists):
// a malformed browser request renders the styled error page (spec:
// domain-authorization), which needs the parsed template set.
csrfExemptPaths := []string{"/domains/ask"}
for _, mount := range cfg.RouteMounts {
if err := mount.Register(httpRequestRouter, deps); err != nil {
cfg.Logger.Error("failed to register integration routes", slog.Any("error", err))
return err
}
csrfExemptPaths = append(csrfExemptPaths, mount.CSRFExemptPaths...)
}
// Register Operator page handler
operatorHandler, err := NewOperatorHandler(OperatorHandlerConfig{
AuthConfig: authConfig,
Logger: cfg.Logger,
Database: cfg.Database,
BillingQ: cfg.BillingQ,
EntitlementsQ: cfg.EntitlementsQ,
OrgQ: cfg.OrgQ,
IdentityQ: cfg.IdentityQ,
IntegrationConfigs: cfg.IntegrationConfigs,
})
if err != nil {
cfg.Logger.Error("failed to set up Operator handler", slog.Any("error", err))
return err
}
operatorHandler.RegisterRoutes(httpRequestRouter)
// Register Operator HTMX partials handlers
operatorPartialsHandler, err := NewOperatorPartialsHandler(OperatorPartialsConfig{
EntitlementsQ: cfg.EntitlementsQ,
BillingQ: cfg.BillingQ,
StripeQ: cfg.StripeQ,
Database: cfg.Database,
IdentityQ: cfg.IdentityQ,
OrgQ: cfg.OrgQ,
Logger: cfg.Logger,
AuthConfig: authConfig,
StripeDashboardURL: cfg.StripeDashboardURL,
StripeConfigured: cfg.StripeAPIKey != "" && cfg.StripeWebhookSecret != "",
TemporalClient: cfg.TemporalClient,
// The operator Domains surface reads and moderates claims through
// the registry, under the deployment's policy — the same policy the
// member surface allocates under, since the page prints it as the
// effective one.
Registry: domains.NewRegistry(cfg.Database, domains.WithPolicy(cfg.DomainsPolicy)),
IntegrationConfigs: cfg.IntegrationConfigs,
})
if err != nil {
cfg.Logger.Error("failed to set up Operator partials handler", slog.Any("error", err))
return err
}
operatorPartialsHandler.RegisterRoutes(httpRequestRouter)
// Register Workspace partials handlers
workspacePartialsHandler, err := NewWorkspacePartialsHandler(WorkspacePartialsConfig{
OrgQ: cfg.OrgQ,
EntitlementsQ: cfg.EntitlementsQ,
Database: cfg.Database,
AuthConfig: authConfig,
Logger: cfg.Logger,
})
if err != nil {
cfg.Logger.Error("failed to set up Workspace partials handler", slog.Any("error", err))
return err
}
workspacePartialsHandler.RegisterRoutes(httpRequestRouter)
// Register Member Products partials handlers
memberProductsHandler, err := NewMemberProductsHandler(MemberProductsConfig{
EntitlementsQ: cfg.EntitlementsQ,
BillingQ: cfg.BillingQ,
AuthConfig: authConfig,
Logger: cfg.Logger,
Database: cfg.Database,
})
if err != nil {
cfg.Logger.Error("failed to set up Member Products handler", slog.Any("error", err))
return err
}
memberProductsHandler.RegisterRoutes(httpRequestRouter)
// Register Member Invoices partials handler (Billing page)
memberInvoicesHandler, err := NewMemberInvoicesHandler(MemberInvoicesConfig{
BillingQ: cfg.BillingQ,
AuthConfig: authConfig,
Logger: cfg.Logger,
})
if err != nil {
cfg.Logger.Error("failed to set up Member Invoices handler", slog.Any("error", err))
return err
}
memberInvoicesHandler.RegisterRoutes(httpRequestRouter)
// Register Member Domains partials handler (Domains page). The connect
// target is the domains-connect-target core key, resolved once by the
// composition root (design D7): an empty value disables external claims
// deployment-wide.
memberDomainsHandler, err := NewMemberDomainsHandler(MemberDomainsConfig{
DomainsQ: cfg.DomainsQ,
// Member-facing registry: MUST carry the external-claim gate so
// ClaimExternal enforces the plan gate itself
// (centralize-external-claim-gate).
Registry: domains.NewRegistry(cfg.Database, domains.WithPolicy(cfg.DomainsPolicy),
domains.WithExternalClaimGate(NewExternalClaimGate(cfg.EntitlementsQ, cfg.DomainsConnectTarget))),
EntitlementsQ: cfg.EntitlementsQ,
TemporalClient: cfg.TemporalClient,
AuthConfig: authConfig,
Logger: cfg.Logger,
ConnectTarget: cfg.DomainsConnectTarget,
})
if err != nil {
cfg.Logger.Error("failed to set up Member Domains handler", slog.Any("error", err))
return err
}
memberDomainsHandler.RegisterRoutes(httpRequestRouter)
// Register billing checkout handler. The stripe-go package-level API key
// is set exactly once, by the Stripe integration's Startup hook
// (internal/integrations/stripe.Adapter.Startup) — not here (design.md
// Decision 5; openspec/changes/integration-extraction task 3.4).
if cfg.StripeAPIKey != "" {
billingCheckout := &BillingCheckoutHandler{
Database: cfg.Database,
BillingQ: cfg.BillingQ,
AuthConfig: authConfig,
Logger: cfg.Logger,
BaseURL: cfg.BaseURL,
}
httpRequestRouter.HandleFunc("POST /billing/checkout", billingCheckout.HandleCheckout)
}
// Create CORS configuration with default options
corsOptions := cors.Options{
// Define minimal defaults - GET method is required
AllowedMethods: []string{"GET"},
}
// Create empty CSRF configuration with default values
var csrfConfig middleware.CSRFConfig
// Get and validate CSRF secret from config
csrfKey, err := middleware.ParseCSRFKey(cfg.CSRFSecret)
if err != nil {
cfg.Logger.Error("invalid csrf-secret",
slog.String("error", err.Error()),
slog.String("hint", "must be exactly 32 bytes and persist across restarts"))
return err
}
csrfConfig.Secret = csrfKey
// Bypass CSRF for the exempt paths collected above: core's own
// /domains/ask (called by a TLS proxy mid-handshake) plus whatever each
// integration declared via RouteMount.CSRFExemptPaths (e.g. the Stripe
// webhook, which verifies its own provider signature instead of a CSRF
// token) — declared by the route, not hardcoded here.
for _, exemptPath := range csrfExemptPaths {
exemptPath := exemptPath
csrfConfig.Ignore = append(csrfConfig.Ignore, func(r *http.Request) bool {
return r.URL.Path == exemptPath
})
}
// Add CSRF error handler for debugging
csrfConfig.ErrorHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cfg.Logger.Error("CSRF validation failed",
slog.String("path", r.URL.Path),
slog.String("method", r.Method),
slog.String("reason", csrf.FailureReason(r).Error()),
slog.String("origin", r.Header.Get("Origin")),
slog.String("referer", r.Header.Get("Referer")))
// Include "CSRF" in response for client-side detection
// This allows the frontend to preserve user input and show a helpful message
http.Error(w, "CSRF token invalid or expired. Please refresh the page.", http.StatusForbidden)
})
// Only override specific settings when needed
if cfg.Env == "development" {
// In development, cookies often need to work without HTTPS
csrfConfig.Cookie.Secure = false
}
// Always set cookie path to "/" to avoid multiple CSRF cookies with different paths
// Without this, gorilla/csrf creates separate cookies for different URL paths,
// causing token mismatches (e.g., token from "/" doesn't match cookie from "/partials/fedwiki")
csrfConfig.Cookie.Path = "/"
// Add base URL as trusted origin for CSRF validation
// gorilla/csrf expects just host:port, not the full URL with scheme
baseURL := viper.GetString("base-url")
if baseURL != "" {
// Parse the URL to extract just the host
if parsed, err := url.Parse(baseURL); err == nil && parsed.Host != "" {
csrfConfig.TrustedOrigins = []string{parsed.Host}
cfg.Logger.Info("CSRF trusted origins configured", slog.Any("origins", csrfConfig.TrustedOrigins))
}
}
// For embedded templates
templateSubFS, err := fs.Sub(embeds.Templates, "templates")
if err != nil {
cfg.Logger.Error("Failed to create sub filesystem for templates", slog.Any("error", err))
return err
}
// Parse templates from embedded FS. The glob picks up operator.html
// which references the `renderBody` template func from operator_pages.go;
// register a stub here so parse succeeds. The real implementation is
// wired up in NewOperatorPartialsHandler's own template set. routeURL is
// registered for real (not stubbed) because this set is also where
// cfg.UIMounts' templates land (composeUITemplates below) — an
// integration's own templates parsed into this set may reference it,
// as FedWiki's do (fedwiki_sites.html, fedwiki_delete_confirm.html).
tmpl := template.Must(template.New("root").Funcs(template.FuncMap{
"renderBody": func(string, any) (template.HTML, error) { return "", nil },
"routeURL": web.RouteURL,
"deploymentName": config.DeploymentName,
}).ParseFS(templateSubFS, "*.html"))
// Compose in each installed integration's templates (cfg.UIMounts —
// see the UIMount doc comment). FedWiki's member-card templates ride
// this seam (as any integration's may); slug-prefix validation guards
// the namespace.
tmpl = composeUITemplates(tmpl, cfg.UIMounts)
safeTmpl := NewSafeTemplates(tmpl, cfg.Logger)
httpRequestRouter.HandleFunc("GET /domains/ask", DomainAskHandler(
domains.NewRegistryAuthorizer(cfg.DomainsQ, cfg.AskFallbackURL, cfg.Logger), cfg.Logger, safeTmpl))
// Create the middleware stack that runs on EVERY request, matched route
// or not. Auth is deliberately excluded here — it is applied separately,
// below, only once a request is known to match a real route. That split
// is what lets an anonymous visit to an unmatched path reach the styled
// 404 without a session (design.md Decision 4; spec: error-pages).
preAuthStack := middleware.CreateStack(
middleware.RequestID(), // Generate a unique request ID
middleware.Logging(), // Log requests with structured logging
// Catch all panics; the styled 500 page never echoes the panic value
middleware.Recovery(func(w http.ResponseWriter, r *http.Request) {
safeTmpl.RenderErrorPage(w, r, http.StatusInternalServerError, "An unexpected error occurred. Please try again.")
}),
middleware.Timeout(32*time.Second), // Set request timeout
middleware.MaxBodySize(1024*1024), // 1MB size limit
middleware.SecureHeaders(), // Set secure headers
middleware.CORS(corsOptions), // CORS configuration
middleware.CSRF(csrfConfig), // CSRF protection
middleware.Compress(), // Response compression
authConfig.SessionManager.LoadAndSave, // Session management (must be before auth middleware)
)
// Signature-verified integration endpoints (webhooks) bypass session
// auth — without this, provider deliveries 302 to /login (caught
// live: Discourse webhook, 2026-07-20). newRouteAwareHandler only runs
// this for requests it has already confirmed match a real pattern.
routeAware := newRouteAwareHandler(httpRequestRouter, authConfig.Middleware(csrfExemptPaths...), safeTmpl)
// Create HTTP server
server := http.Server{
Addr: ":" + cfg.Port,
Handler: preAuthStack(routeAware),
ReadTimeout: 4 * time.Second,
WriteTimeout: 8 * time.Second,
IdleTimeout: 16 * time.Second,
MaxHeaderBytes: 1024 * 1024, // 1MB
BaseContext: func(_ net.Listener) context.Context { return ctx }, // Pass base context to all requests
}
// Guard: any unmatched /partials/ path returns 404 rather than falling
// through to the "/" catch-all (which would serve the dashboard HTML
// inside an HTMX swap target — silently broken). Specific /partials/
// routes registered above this line still take precedence because Go's
// ServeMux prefers longer/more-specific patterns.
httpRequestRouter.HandleFunc("/partials/", func(w http.ResponseWriter, r *http.Request) {
safeTmpl.RenderErrorPage(w, r, http.StatusNotFound, "The page you requested does not exist.")
})
// Serve index.html via template rendering
httpRequestRouter.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
// The "/" pattern matches every otherwise-unrouted path. Only the
// dashboard URL itself is valid here; anything else is a 404, not a
// silently served dashboard.
if r.URL.Path != "/" {
safeTmpl.RenderErrorPage(w, r, http.StatusNotFound, "The page you requested does not exist.")
return
}
// Always serve HTML
w.Header().Set("Content-Type", "text/html; charset=utf-8")
// Get session data
ctx := r.Context()
name := authConfig.GetUserName(ctx)
username := authConfig.GetUsername(ctx)
email := authConfig.GetUserEmail(ctx)
// Create Keycloak Account URL
keycloakAccountURL := viper.GetString("oidc-idp-issuer-url") + "/account"
// Get CSRF token for HTMX requests
csrfToken := middleware.CSRFToken(r)
// Check if user has operator role
isOperator := authConfig.HasRole(r, OperatorRole)
// Check workspace count for progressive disclosure
hasMultipleWorkspaces := false
session := authConfig.GetUserSession(ctx)
if session != nil {
wsCount, err := cfg.OrgQ.CountWorkspacesByOrgID(ctx, session.OrgID)
if err == nil && wsCount > 1 {
hasMultipleWorkspaces = true
}
}
// Pending domain claims (dissolve-member-domains): with the standalone
// Domains page gone, the dashboard is the durable re-entry point for a
// claim whose DNS verification is still in flight. Best-effort — a
// lookup failure only costs the notice, never the page.
var pendingDomainClaims []PendingDomainClaim
if session != nil {
claims, err := cfg.DomainsQ.ListLiveClaimsByWorkspace(ctx, session.WorkspaceID)
if err != nil {
cfg.Logger.Warn("dashboard: pending domain claims lookup failed",
slog.String("workspace_id", session.WorkspaceID), slog.Any("error", err))
}
for _, c := range claims {
if c.Status == domains.StatusPending {
pendingDomainClaims = append(pendingDomainClaims, PendingDomainClaim{
ClaimID: c.ClaimID, Root: c.RootFqdn,
})
}
}
}
// Post-checkout return banner. Stripe Checkout redirects back to
// "/?checkout=success|cancel". Only recognize the known values; plan
// activation is driven asynchronously by the checkout.session.completed
// webhook, so "success" reports payment received, not an applied tier.
checkoutStatus := ""
switch r.URL.Query().Get("checkout") {
case "success":
checkoutStatus = "success"
case "cancel":
checkoutStatus = "cancel"
}
// On a successful return, eagerly reconcile the member's latest Stripe
// subscription so a freshly purchased plan reflects immediately, without
// waiting on webhook delivery. Best-effort: see eagerReconcileOnReturn.
if checkoutStatus == "success" && cfg.StripeAPIKey != "" && session != nil {
eagerReconcileOnReturn(ctx, cfg, session.OrgID)
}
data := struct {
Name string
Username string
Email string
KeycloakAccountURL string
CSRFToken string
IsOperator bool
HasMultipleWorkspaces bool
CheckoutStatus string
DashboardCards []DashboardCard
PendingDomainClaims []PendingDomainClaim
}{Name: name, Username: username, Email: email, KeycloakAccountURL: keycloakAccountURL, CSRFToken: csrfToken, IsOperator: isOperator, HasMultipleWorkspaces: hasMultipleWorkspaces, CheckoutStatus: checkoutStatus, DashboardCards: cfg.DashboardCards, PendingDomainClaims: pendingDomainClaims}
safeTmpl.Render(w, "index.html", data)
})
// Serve products.html via template rendering
httpRequestRouter.HandleFunc("GET /products", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
keycloakAccountURL := viper.GetString("oidc-idp-issuer-url") + "/account"
csrfToken := middleware.CSRFToken(r)
isOperator := authConfig.HasRole(r, OperatorRole)
data := struct {
KeycloakAccountURL string
CSRFToken string
IsOperator bool
}{KeycloakAccountURL: keycloakAccountURL, CSRFToken: csrfToken, IsOperator: isOperator}
safeTmpl.Render(w, "products.html", data)
})
// Serve billing.html (member invoice & payment history) via template rendering
httpRequestRouter.HandleFunc("GET /billing", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
keycloakAccountURL := viper.GetString("oidc-idp-issuer-url") + "/account"
csrfToken := middleware.CSRFToken(r)
isOperator := authConfig.HasRole(r, OperatorRole)
data := struct {
KeycloakAccountURL string
CSRFToken string
IsOperator bool
}{KeycloakAccountURL: keycloakAccountURL, CSRFToken: csrfToken, IsOperator: isOperator}
safeTmpl.Render(w, "billing.html", data)
})
// The standalone member Domains page is dissolved (dissolve-member-domains):
// claims are managed at point of use, in the Domains section nested inside
// the FedWiki Sites dashboard card, all via the same /partials/domains/...
// routes registered above. Stale links and bookmarks land on that section's
// fragment anchor (ux-ia-naming D7) rather than a bare dashboard redirect,
// so a member following a domains link lands on domains content, not just
// the dashboard.
httpRequestRouter.HandleFunc("GET /domains", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/#domains", http.StatusFound)
})
// For embedded static files
httpRequestRouter.Handle("/static/", http.FileServer(http.FS(embeds.Static)))
// Mount each installed integration's static assets (cfg.UIMounts) under
// the existing /static/ route at its own /static/<slug>/ subpath — same
// origin, no new route surface, no CSP change. No integration supplies
// static assets yet, so this loop is currently a no-op.
for _, mount := range cfg.UIMounts {
if mount.Static == nil {
continue
}
prefix := "/static/" + mount.Slug + "/"
httpRequestRouter.Handle(prefix, http.StripPrefix(prefix, http.FileServer(http.FS(mount.Static))))
}
// Log server startup with structured logging
cfg.Logger.Info("starting server",
slog.String("port", cfg.Port),
slog.String("environment", cfg.Env),
slog.String("address", "http://localhost:"+cfg.Port))
// Start server and log any errors
if err := server.ListenAndServe(); err != nil {
cfg.Logger.Error("server error", slog.Any("error", err))
return err
}
return nil
}
// eagerReconcileOnReturn reconciles the org's latest Stripe subscription after a
// successful checkout return, so a freshly purchased plan reflects immediately
// rather than waiting on webhook delivery. It is best-effort: any failure is
// logged and never surfaced to the member, since the webhook path remains
// responsible for eventual consistency. Runs under a short timeout so the
// dashboard render is never blocked.
func eagerReconcileOnReturn(ctx context.Context, cfg Config, orgID string) {
accounts, err := billing.New(cfg.Database).ListBillingAccountsByOrgID(ctx, orgID)
if err != nil {
cfg.Logger.Warn("eager reconcile: list billing accounts",
slog.String("org_id", orgID), slog.Any("error", err))
return
}
if len(accounts) == 0 {
return
}
mapping, err := stripedb.New(cfg.Database).GetCustomerMappingByBillingAccountID(ctx, accounts[0].BillingAccountID)
if err != nil || !mapping.StripeCustomerID.Valid {
return // no synced customer mapping yet — nothing to reconcile
}
rctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := fulfillment.ReconcileLatestSubscriptionForCustomer(
rctx, cfg.Database, cfg.Logger, mapping.StripeCustomerID.String, "eager:checkout-return",
); err != nil {
cfg.Logger.Warn("eager reconcile on checkout return failed (webhook will reconcile)",
slog.String("org_id", orgID), slog.Any("error", err))
}
}