newRouter keeps the mux, the auth routes, the include, the certificate ask, the failure template, the route-aware dispatch and the /partials/ guard. It calls registerIntegrationRoutes (the Deps, the exempt seed and the mount loop), registerCoreHandlers (the six constructors and Checkout), newCrossOriginProtection (the trusted origin, the bypass list and the deny handler), rootMemberTemplates, preAuthStack (the CORS options and the ten middlewares), registerMemberPages and registerStatic, in today's order. The dashboard reads multipleWorkspaces, pendingClaims and checkoutReturn, whose bodies are the closure's blocks. The trusted origin is still the base-url setting, read once in newRouter and passed in. The gocognit exception for newRouter is deleted; the largest function in server.go scores 11. Comment fixes: the static loop's "no-op" sentence is gone; the stray "Trigger returns..." lines are gone and DashboardCardProvider's comment sits on the type; the RouteMount, UIMount and newRouteAwareHandler comments name NewHandler as the builder. Shown to change no output: the names and results of internal/server and internal/testkit are identical to the parent's (1435 tests, all passing), routes.golden and route_pages.golden are unchanged, and the walkthroughs (22 of 22) and every screen equal the baseline apart from the Stripe integration page, whose size changes on every run. 71 breaks, each caught by the old and the new test; two records are dropped because the route-pages test misses them on both sides: the operator domains page reading the registry, and the operator partials reading the integrations' configuration (the page answers the same status and heading without the read). The dashboard's write-before-read order is probed: the reconcile conferring a rung before the page reads the organization's holdings is caught by the success-return subtest that expects no empty state. Left unprobed: the reconcile's order against the workspaces include alone, because the region shows workspaces and not holdings.
959 lines
43 KiB
Go
959 lines
43 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"
|
|
"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
|
|
// StripeKeyMode answers the mode of the API key the Stripe SDK holds at
|
|
// the moment of the call; Checkout reads it per request where StripeMode
|
|
// is the boot-time value.
|
|
StripeKeyMode func() 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); NewHandler consumes the slice without
|
|
// naming any integration handler type, and CSRF exemptions are declared by
|
|
// the route instead of hardcoded in NewHandler.
|
|
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).
|
|
//
|
|
// The member template set built in NewHandler (web.MemberTemplates) takes the
|
|
// files of Templates except <Key>_operator*.html, which belong to the
|
|
// operator surface and are parsed only by the operator set the integration's
|
|
// own handler builds. The key-prefix namespacing rule (design.md Decision 8)
|
|
// covers every file: every template name introduced from Templates must be
|
|
// prefixed with Key, or NewHandler 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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.
|
|
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 NewHandler) 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_entitlements.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 positions, err := entitlementsQ.ListLadderPositionsByPool(ctx, pool.PoolID); err == nil && len(positions) > 0 {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Start builds the console's handler with NewHandler and serves it on
|
|
// cfg.Port until the server fails.
|
|
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")
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
handler, err := NewHandler(cfg, authConfig)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Create HTTP server
|
|
server := http.Server{
|
|
Addr: ":" + cfg.Port,
|
|
Handler: handler,
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// NewHandler builds the console's HTTP handler from cfg: the auth routes,
|
|
// each mount in cfg.RouteMounts and cfg.UIMounts, every core route, and the
|
|
// middleware that runs before them. It sets authConfig.Failures to the
|
|
// member template set. cfg.DomainsQ must be set; Start refuses a Config
|
|
// without it before it connects to anything. With cfg.StripeAPIKey set,
|
|
// cfg.StripeKeyMode must be set too: Checkout calls it on every request, and
|
|
// a nil one answers every checkout with a 500.
|
|
func NewHandler(cfg Config, authConfig *auth.Config) (http.Handler, error) {
|
|
rt, err := newRouter(cfg, authConfig)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return rt.handler, nil
|
|
}
|
|
|
|
// router is what NewHandler builds, with the mux the handler dispatches
|
|
// through, so a test in this package can resolve a request to its pattern.
|
|
type router struct {
|
|
mux *http.ServeMux
|
|
handler http.Handler
|
|
}
|
|
|
|
// newRouter builds NewHandler's handler and keeps its mux.
|
|
func newRouter(cfg Config, authConfig *auth.Config) (*router, error) {
|
|
// Create a new HTTP request router
|
|
httpRequestRouter := http.NewServeMux()
|
|
|
|
// Register auth handlers
|
|
authConfig.RegisterHandlers(httpRequestRouter)
|
|
|
|
include := NewInclude(httpRequestRouter)
|
|
csrfExemptPaths, err := registerIntegrationRoutes(httpRequestRouter, cfg, authConfig, include)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := registerCoreHandlers(httpRequestRouter, cfg, authConfig); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
csrfMiddleware, err := newCrossOriginProtection(cfg.Logger, viper.GetString("base-url"), csrfExemptPaths)
|
|
if err != nil {
|
|
cfg.Logger.Error("invalid cross-origin protection configuration", slog.Any("error", err))
|
|
return nil, err
|
|
}
|
|
|
|
safeTmpl := NewSafeTemplates(template.Must(rootMemberTemplates(cfg.UIMounts)), 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))
|
|
|
|
// 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)
|
|
|
|
handler := preAuthStack(safeTmpl, csrfMiddleware, authConfig.SessionManager.LoadAndSave)(routeAware)
|
|
|
|
// 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.")
|
|
})
|
|
|
|
registerMemberPages(httpRequestRouter, cfg, authConfig, include, safeTmpl)
|
|
registerStatic(httpRequestRouter, cfg.UIMounts)
|
|
|
|
return &router{mux: httpRequestRouter, handler: handler}, nil
|
|
}
|
|
|
|
// registerIntegrationRoutes registers each installed integration's HTTP
|
|
// routes (FedWiki, the Stripe webhook, ...) via cfg.RouteMounts — see the
|
|
// Deps/RouteMount doc comments above — and returns the request paths that
|
|
// bypass cross-origin protection and session auth. Core never constructs an
|
|
// integration handler directly; the exemptions are collected from each
|
|
// mount instead of being hardcoded in the CSRF config.
|
|
func registerIntegrationRoutes(httpRequestRouter *http.ServeMux, cfg Config, authConfig *auth.Config, include Include) ([]string, error) {
|
|
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: include,
|
|
}
|
|
// 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. /domains/ask
|
|
// is unauthenticated by design: the TLS-terminating proxy that calls it
|
|
// during a handshake holds no session.
|
|
// The handler itself is registered later (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 nil, err
|
|
}
|
|
csrfExemptPaths = append(csrfExemptPaths, mount.CSRFExemptPaths...)
|
|
}
|
|
return csrfExemptPaths, nil
|
|
}
|
|
|
|
// registerCoreHandlers constructs and registers the Operator page, Operator
|
|
// partials, Workspace partials, Member Products, Member Invoices and Member
|
|
// Domains handlers, and Checkout when a Stripe key is configured.
|
|
func registerCoreHandlers(httpRequestRouter *http.ServeMux, cfg Config, authConfig *auth.Config) error {
|
|
// 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,
|
|
|
|
StripeKeyFingerprint: cfg.StripeKeyFingerprint,
|
|
})
|
|
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,
|
|
EntitlementsQ: cfg.EntitlementsQ,
|
|
KeyMode: cfg.StripeKeyMode,
|
|
}
|
|
httpRequestRouter.HandleFunc("POST /billing/checkout", billingCheckout.HandleCheckout)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// newCrossOriginProtection builds the 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. It trusts
|
|
// the origin of baseURL, and exempt lists the paths that bypass it.
|
|
func newCrossOriginProtection(logger *slog.Logger, baseURL string, exempt []string) (middleware.Middleware, error) {
|
|
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.
|
|
if baseURL != "" {
|
|
if parsed, err := url.Parse(baseURL); err == nil && parsed.Scheme != "" && parsed.Host != "" {
|
|
origin := parsed.Scheme + "://" + parsed.Host
|
|
csrfConfig.TrustedOrigins = []string{origin}
|
|
logger.Info("cross-origin protection trusts", slog.String("origin", origin))
|
|
} else {
|
|
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 from the routes: 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, exempt...)
|
|
|
|
csrfConfig.DenyHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
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.", http.StatusForbidden)
|
|
})
|
|
|
|
return middleware.CSRF(csrfConfig)
|
|
}
|
|
|
|
// rootMemberTemplates builds the member surface's template set: the core
|
|
// pages and partials plus each installed integration's member-side templates
|
|
// (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 and a violation panics at boot.
|
|
func rootMemberTemplates(uiMounts []UIMount) (*template.Template, error) {
|
|
mounts := make([]web.Mount, len(uiMounts))
|
|
for i, m := range uiMounts {
|
|
mounts[i] = web.Mount{Key: m.Key, FS: m.Templates}
|
|
}
|
|
return web.MemberTemplates(web.CoreTemplates(), mounts...)
|
|
}
|
|
|
|
// preAuthStack wraps next in the middleware that runs on EVERY request,
|
|
// matched route or not. Auth is deliberately excluded here — it is applied
|
|
// separately, 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).
|
|
func preAuthStack(safeTmpl *SafeTemplates, csrfMiddleware, loadAndSave middleware.Middleware) middleware.Middleware {
|
|
corsOptions := cors.Options{
|
|
// Define minimal defaults - GET method is required
|
|
AllowedMethods: []string{"GET"},
|
|
}
|
|
return 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
|
|
loadAndSave, // Session management (must be before auth middleware)
|
|
)
|
|
}
|
|
|
|
// registerMemberPages registers the member pages: the dashboard at "/", the
|
|
// Products and Billing pages, and the redirect from the dissolved Domains
|
|
// page. Each composes its regions in-process through include.
|
|
func registerMemberPages(httpRequestRouter *http.ServeMux, cfg Config, authConfig *auth.Config, include Include, safeTmpl *SafeTemplates) {
|
|
// 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()
|
|
|
|
// Check if user has operator role
|
|
isOperator := authConfig.HasRole(r, OperatorRole)
|
|
|
|
session := authConfig.GetUserSession(ctx)
|
|
hasMultipleWorkspaces := multipleWorkspaces(ctx, cfg.OrgQ, session)
|
|
pendingDomainClaims := pendingClaims(ctx, cfg.DomainsQ, cfg.Logger, session)
|
|
checkoutStatus := checkoutReturn(r)
|
|
|
|
// 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(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(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}
|
|
|
|
if checkoutStatus == "success" {
|
|
data.BillingFacts = loadBillingFacts(ctx, cfg.Database, cfg.StripeKeyFingerprint, cfg.Logger)
|
|
}
|
|
|
|
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(include, cfg.Logger, r, "/partials/member/entitlements", "your entitlements"),
|
|
Plans: IncludeOr(include, cfg.Logger, r, "/partials/member/plans", "plans"),
|
|
Addons: IncludeOr(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(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)
|
|
})
|
|
}
|
|
|
|
// multipleWorkspaces reports whether the session's organization has more
|
|
// than one workspace, for the dashboard's progressive disclosure. A missing
|
|
// session or a failed count is false.
|
|
func multipleWorkspaces(ctx context.Context, orgQ organization.Querier, session *auth.UserSession) bool {
|
|
if session == nil {
|
|
return false
|
|
}
|
|
wsCount, err := orgQ.CountWorkspacesByOrgID(ctx, session.OrgID)
|
|
if err == nil && wsCount > 1 {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// pendingClaims lists the session's workspace's domain claims whose DNS
|
|
// verification is still in flight (dissolve-member-domains): with the
|
|
// standalone Domains page gone, the dashboard is the durable re-entry point
|
|
// for such a claim. Best-effort — a lookup failure only costs the notice,
|
|
// never the page.
|
|
func pendingClaims(ctx context.Context, domainsQ domains.Querier, logger *slog.Logger, session *auth.UserSession) []PendingDomainClaim {
|
|
if session == nil {
|
|
return nil
|
|
}
|
|
var pending []PendingDomainClaim
|
|
claims, err := domainsQ.ListLiveClaimsByWorkspace(ctx, session.WorkspaceID)
|
|
if err != nil {
|
|
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 {
|
|
pending = append(pending, PendingDomainClaim{
|
|
ClaimID: c.ClaimID, Root: c.RootFqdn,
|
|
})
|
|
}
|
|
}
|
|
return pending
|
|
}
|
|
|
|
// checkoutReturn returns which post-checkout banner the dashboard shows, from
|
|
// the checkout query parameter. Stripe Checkout redirects back to
|
|
// "/?checkout=success|cancel". Only the known values are recognized; plan
|
|
// activation is driven asynchronously by the checkout.session.completed
|
|
// webhook, so "success" reports payment received, not an applied tier.
|
|
func checkoutReturn(r *http.Request) string {
|
|
checkoutStatus := ""
|
|
switch r.URL.Query().Get("checkout") {
|
|
case "success":
|
|
checkoutStatus = "success"
|
|
case "cancel":
|
|
checkoutStatus = "cancel"
|
|
}
|
|
return checkoutStatus
|
|
}
|
|
|
|
// registerStatic serves the embedded static files and mounts each installed
|
|
// integration's static assets (uiMounts) under the existing /static/ route
|
|
// at its own /static/<key>/ subpath — same origin, no new route surface, no
|
|
// CSP change.
|
|
func registerStatic(httpRequestRouter *http.ServeMux, uiMounts []UIMount) {
|
|
// For embedded static files
|
|
httpRequestRouter.Handle("/static/", http.FileServer(http.FS(embeds.Static)))
|
|
|
|
for _, mount := range uiMounts {
|
|
if mount.Static == nil {
|
|
continue
|
|
}
|
|
prefix := "/static/" + mount.Key + "/"
|
|
httpRequestRouter.Handle(prefix, http.StripPrefix(prefix, http.FileServer(http.FS(mount.Static))))
|
|
}
|
|
}
|
|
|
|
// 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))
|
|
}
|
|
}
|