- 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
835 lines
36 KiB
Go
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))
|
|
}
|
|
}
|