Files
member-console/internal/server/server.go
T

932 lines
42 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
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/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
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
// StripeMode is "test" or "live", derived at boot from the API key's
// prefix ("" when no key is configured). Every surface that shows or
// acts on the mode reads it from here, so none can disagree with the
// key in force (stripe-integration-infrastructure, "Stripe mode is
// derived from the API key").
StripeMode string
// StripeKeyFingerprint is the SHA-256 digest of the API key, derived
// at boot beside the mode and never the key itself. The Stripe
// provider page's Environment check section compares it with the
// fingerprint the last check recorded, which is how the page says the
// key changed since (stripe-environment-stamp D3).
StripeKeyFingerprint string
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 — key, 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 {
Key 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
// Include composes another GET route's fragment into a page in-process
// (server-side composition, spec page-anatomy "A page arrives
// complete"): an integration renders its card body's nested regions
// with it instead of fetching them after load.
Include Include
}
// 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 key-prefix namespacing rule (design.md Decision 8): every
// template name introduced from Templates must be prefixed with Key, 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-key
// subpath /static/<Key>/ — same-origin, no new route surface, no CSP
// change.
type UIMount struct {
Key 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 key-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 Key (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.Key, err))
}
for _, name := range names {
if !strings.HasPrefix(name, mount.Key) {
panic(fmt.Sprintf(
"integration %q registered template %q without the required %q key prefix",
mount.Key, name, mount.Key))
}
}
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
// Description is an optional one-sentence lead rendered under the
// title in the page-header lead style (design D7); "" renders nothing.
Description 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/<key>/ 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.
// 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)
})
}
// IsEmpty reports whether the dashboard has nothing to show: no
// integration declared a card and the organization holds no entitlement
// (member-dashboard ADDED requirement "The dashboard has one empty
// state"; design D25). The template renders one emptyState in place of
// the cards section when this is true; otherwise nothing changes.
func (d IndexPageData) IsEmpty() bool {
return len(d.DashboardCards) == 0 && !d.HasEntitlement
}
// orgHasEntitlement reports whether the person's organization holds any
// entitlement: a numeric or granted-boolean entitlement in its default
// pool, or a rung on any plan ladder — the same facts
// member_products.go's GetEntitlements combines into HasEntitlements /
// Enrolled for the Products page's own empty state, condensed to one bool
// since the dashboard renders no entitlement detail of its own. An
// org with no default pool yet (no entitlement of any kind) degrades to
// false, the same as every entitlements lookup elsewhere.
func orgHasEntitlement(ctx context.Context, entitlementsQ entitlements.Querier, orgID string) bool {
pool, err := entitlementsQ.GetDefaultPoolByOrgID(ctx, orgID)
if err != nil {
return false
}
if nums, err := entitlementsQ.ListNumericEntitlementsByPoolID(ctx, pool.PoolID); err == nil && len(nums) > 0 {
return true
}
if bools, err := entitlementsQ.ListBooleanEntitlementsByPoolID(ctx, pool.PoolID); err == nil {
for _, b := range bools {
if b.Granted {
return true
}
}
}
if attachments, err := entitlementsQ.GetActiveAttachmentsByPool(ctx, pool.PoolID); err == nil && len(attachments) > 0 {
return true
}
return false
}
// 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,
Include: NewInclude(httpRequestRouter),
}
// 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
// the cross-origin bypass patterns 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,
StripeMode: cfg.StripeMode,
})
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,
StripeMode: cfg.StripeMode,
StripeKeyFingerprint: cfg.StripeKeyFingerprint,
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,
StripeMode: cfg.StripeMode,
})
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,
StripeQ: cfg.StripeQ,
AuthConfig: authConfig,
Logger: cfg.Logger,
StripeMode: cfg.StripeMode,
})
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"},
}
// Cross-origin request protection (net/http.CrossOriginProtection).
// There is no token: the standard library rejects non-safe cross-origin
// requests by reading Sec-Fetch-Site, falling back to comparing Origin's
// hostname with Host. This replaced gorilla/csrf in 2026-09, which carried
// GO-2025-3884 with no fixed release.
var csrfConfig middleware.CSRFConfig
// The deployment's own origin, scheme included. gorilla/csrf compared only
// the host here, which is exactly the advisory: a policy written for https
// was equally satisfied over plain http. AddTrustedOrigin rejects an entry
// without a scheme, so this is a full origin or nothing.
baseURL := viper.GetString("base-url")
if baseURL != "" {
if parsed, err := url.Parse(baseURL); err == nil && parsed.Scheme != "" && parsed.Host != "" {
origin := parsed.Scheme + "://" + parsed.Host
csrfConfig.TrustedOrigins = []string{origin}
cfg.Logger.Info("cross-origin protection trusts", slog.String("origin", origin))
} else {
cfg.Logger.Warn("base-url is not a usable origin; only same-origin requests will be accepted",
slog.String("base-url", baseURL))
}
}
// Exempt the paths collected above: core's own /domains/ask (called by a
// TLS proxy mid-handshake) plus whatever each integration declared via
// RouteMount.CSRFExemptPaths (the Stripe and Discourse webhooks, which
// verify a provider signature instead). Declared by the route, not
// hardcoded here. Each is a deliberate hole in this protection.
csrfConfig.BypassPatterns = append(csrfConfig.BypassPatterns, csrfExemptPaths...)
csrfConfig.DenyHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cfg.Logger.Error("cross-origin request refused",
slog.String("path", r.URL.Path),
slog.String("method", r.Method),
slog.String("sec_fetch_site", r.Header.Get("Sec-Fetch-Site")),
slog.String("origin", r.Header.Get("Origin")))
// The body keeps the literal "CSRF" so client-side code can detect this
// case and preserve the person's input rather than losing the form.
http.Error(w, "CSRF check failed: this request did not come from this site. Refresh the page.", http.StatusForbidden)
})
csrfMiddleware, err := middleware.CSRF(csrfConfig)
if err != nil {
cfg.Logger.Error("invalid cross-origin protection configuration", slog.Any("error", err))
return err
}
// 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_custom_domain_pending.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,
"supportURL": config.SupportURL,
"helpIcon": helpIcon,
// operator.html rides the *.html glob only so it parses, but its
// <title> calls pageTitle, and a missing function is a parse
// error this template.Must would panic on at boot.
"pageTitle": pageTitle,
}).ParseFS(templateSubFS, "*.html"))
// The shared application shell and the page-anatomy parts are written
// once under partials/ and parsed into every set by one helper.
tmpl = template.Must(web.ParseUIPartials(tmpl))
// The member surface's root crumb (design D18): this "root" set backs
// the / , /products, and /billing handlers below. operator.html is
// also parsed into this set (the *.html glob above sweeps it up, only
// so it parses — its real, executed tree is OperatorHandler's own set,
// wired with the operator's surfaceRoot in operator.go), so this
// override is never actually reached by an operator.html render.
// Overrides web.ParseUIPartials's no-root-crumb default; must run
// after it to win (html/template resolves a function call against the
// set's current function map at execution time).
tmpl = tmpl.Funcs(template.FuncMap{"surfaceRoot": MemberSurfaceRoot})
// 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); key-prefix validation guards
// the namespace.
tmpl = composeUITemplates(tmpl, cfg.UIMounts)
safeTmpl := NewSafeTemplates(tmpl, cfg.Logger)
// Give the auth package the styled page for a refused sign-in, sign-out
// or registration. It cannot reach the template set on its own (this
// package imports it, not the other way round), so it declares the
// interface and this satisfies it. Assigned here rather than at
// auth.Setup because the template set does not exist until now; the
// handlers registered above only read the field when a request arrives.
authConfig.Failures = safeTmpl
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. Try again.")
}),
// Headers before the timeout and the body limit: both answer for the
// handler (a 503, a 413) and the timeout handler drops whatever an
// inner middleware set, so headers set inside them would be missing
// from exactly those responses (2026-09 audit candidate "413
// responses bypass the security-header middleware").
middleware.SecureHeaders(), // Set secure headers
middleware.Timeout(32*time.Second), // Set request timeout
middleware.MaxBodySize(1024*1024), // 1MB size limit
middleware.CORS(corsOptions), // CORS configuration
csrfMiddleware, // Cross-origin request 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.")
})
// memberShell assembles the shared application shell's data for a
// member page (docs/design-system.md, "Application shell"): the person's
// identity for the account menu, the identity provider's account console,
// the operator role for the rail's surface switch, and the rail entry to
// mark active.
memberShell := func(r *http.Request, active string) Shell {
ctx := r.Context()
return Shell{
Surface: ShellMember,
Name: authConfig.GetUserName(ctx),
Username: authConfig.GetUsername(ctx),
Email: authConfig.GetUserEmail(ctx),
KeycloakAccountURL: config.IdPAccountURL(),
IsOperator: authConfig.HasRole(r, OperatorRole),
Active: active,
}
}
// 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 := config.IdPAccountURL()
// Get CSRF token for HTMX requests
// 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)
}
// Server-side composition (page-anatomy "A page arrives complete"):
// the workspaces card and every integration's card body render into
// this response; their partial routes stay the swap sources.
workspaces := IncludeOr(deps.Include, cfg.Logger, r, "/partials/workspaces", "workspaces")
cards := make([]DashboardCardView, 0, len(cfg.DashboardCards))
for _, c := range cfg.DashboardCards {
cards = append(cards, DashboardCardView{DashboardCard: c, Body: IncludeOr(deps.Include, cfg.Logger, r, c.PartialPath, c.Title)})
}
// The dashboard's one empty state (member-dashboard ADDED
// requirement; design D25) needs to know whether the org holds any
// entitlement, independent of whether any card renders — IsEmpty
// combines the two.
hasEntitlement := false
if session != nil {
hasEntitlement = orgHasEntitlement(ctx, cfg.EntitlementsQ, session.OrgID)
}
data := IndexPageData{Shell: memberShell(r, "dashboard"), Name: name, Username: username, Email: email, KeycloakAccountURL: keycloakAccountURL, IsOperator: isOperator, HasMultipleWorkspaces: hasMultipleWorkspaces, CheckoutStatus: checkoutStatus, DashboardCards: cards, Workspaces: workspaces, PendingDomainClaims: pendingDomainClaims, HasEntitlement: hasEntitlement}
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")
// Server-side composition: the three regions render into this
// response; their partial routes stay the swap sources.
data := ProductsPageData{
Shell: memberShell(r, "products"),
Entitlements: IncludeOr(deps.Include, cfg.Logger, r, "/partials/member/entitlements", "your entitlements"),
Plans: IncludeOr(deps.Include, cfg.Logger, r, "/partials/member/plans", "plans"),
Addons: IncludeOr(deps.Include, cfg.Logger, r, "/partials/member/addons", "add-ons"),
}
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")
// Server-side composition: the invoices region renders into this
// response; its partial route stays the swap source.
data := BillingPageData{
Shell: memberShell(r, "billing"),
Invoices: IncludeOr(deps.Include, cfg.Logger, r, "/partials/member/invoices", "invoices"),
}
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/<key>/ 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.Key + "/"
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))
}
}