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// — 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 , 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// mount, rendered as