Pulse/internal/cloudcp/routes.go

274 lines
14 KiB
Go

package cloudcp
import (
"context"
"net/http"
"strings"
"time"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/account"
"github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/admin"
cpauth "github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/auth"
"github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/docker"
"github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/email"
"github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/entitlements"
"github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/handoff"
"github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/portal"
"github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/registry"
cpstripe "github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/stripe"
)
// Deps holds shared dependencies injected into HTTP handlers.
type Deps struct {
Config *CPConfig
Registry *registry.TenantRegistry
Docker *docker.Manager // nil if Docker is unavailable
MagicLinks *cpauth.Service // control plane magic link service
TrialSignupStore *TrialSignupStore
Provisioner *cpstripe.Provisioner
HostedEntitlements *entitlements.Service
Version string
EmailSender email.Sender
}
func publicCloudSignupPath(cfg *CPConfig) string {
if cfg != nil && cfg.PublicCloudSignupEnabled {
return portal.PortalSignupPath
}
return ""
}
// RegisterRoutes wires all HTTP handlers onto the given ServeMux.
func RegisterRoutes(mux *http.ServeMux, deps *Deps) {
webhookLimiter := NewCPRateLimiter(deps.Config.WebhookRateLimitPerMinute, time.Minute)
magicLinkVerifyLimiter := NewCPRateLimiter(deps.Config.MagicLinkVerifyRateLimitPerMinute, time.Minute)
sessionAuthLimiter := NewCPRateLimiter(deps.Config.SessionAuthRateLimitPerMinute, time.Minute)
adminLimiter := NewCPRateLimiter(deps.Config.AdminRateLimitPerMinute, time.Minute)
accountAPILimiter := NewCPRateLimiter(deps.Config.AccountAPIRateLimitPerMinute, time.Minute)
portalAPILimiter := NewCPRateLimiter(deps.Config.PortalAPIRateLimitPerMinute, time.Minute)
trialSignupPageLimiter := NewCPRateLimiter(30, time.Minute)
trialSignupVerificationLimiter := NewCPRateLimiter(6, time.Hour)
trialSignupVerifyLimiter := NewCPRateLimiter(30, time.Minute)
trialSignupCheckoutLimiter := NewCPRateLimiter(12, time.Hour)
trialSignupCompleteLimiter := NewCPRateLimiter(30, time.Minute)
trialSignupRedeemLimiter := NewCPRateLimiter(30, time.Minute)
hostedEntitlementRefreshLimiter := NewCPRateLimiter(60, time.Hour)
publicSignupLimiter := NewCPRateLimiter(30, time.Minute)
publicMagicLinkLimiter := NewCPRateLimiter(20, time.Minute)
publicCloudSignupPath := publicCloudSignupPath(deps.Config)
adminAuth := func(next http.Handler) http.Handler {
return admin.AdminKeyMiddleware(deps.Config.AdminKey, next)
}
sessionAuth := func(next http.Handler) http.Handler {
if deps.MagicLinks == nil {
return adminAuth(next)
}
return requireSessionAuth(deps.MagicLinks, deps.Registry, next)
}
accountSessionAuth := func(extract accountIDExtractor, next http.Handler) http.Handler {
if deps.MagicLinks == nil {
return adminAuth(next)
}
return sessionAuth(requireAccountMembership(deps.Registry, extract, next))
}
accountMutationAuth := requireAnyAccountRole(registry.MemberRoleOwner, registry.MemberRoleAdmin)
rawCommercialLookup := newCommercialIdentityLookup(deps.Config)
portalCommercialLookup := func(ctx context.Context, email string) (*portal.CommercialIdentity, error) {
if rawCommercialLookup == nil {
return nil, nil
}
identity, err := rawCommercialLookup(ctx, email)
if err != nil {
return nil, err
}
if identity == nil {
return nil, nil
}
return &portal.CommercialIdentity{
HasCommercialIdentity: identity.HasCommercialIdentity,
}, nil
}
// Health / readiness are unauthenticated liveness/readiness probes.
mux.HandleFunc("/healthz", admin.HandleHealthz)
mux.HandleFunc("/readyz", admin.HandleReadyz(deps.Registry))
mux.HandleFunc("/favicon.svg", handleControlPlaneFaviconSVG)
mux.HandleFunc("/favicon.ico", handleControlPlaneFaviconICO)
// Status and metrics are private by default.
statusHandler := http.HandlerFunc(admin.HandleStatus(deps.Registry, deps.Version))
if deps.Config.PublicStatus {
mux.Handle("/status", statusHandler)
} else {
mux.Handle("/status", adminAuth(statusHandler))
}
metricsHandler := promhttp.Handler()
if deps.Config.PublicMetrics {
mux.Handle("/metrics", metricsHandler)
} else {
mux.Handle("/metrics", adminAuth(metricsHandler))
}
// Stripe webhook (signature-authenticated)
hostedEntitlements := deps.HostedEntitlements
if hostedEntitlements == nil {
hostedEntitlements = entitlements.NewService(deps.Registry, deps.Config.BaseURL, deps.Config.TrialActivationPrivateKey)
}
provisioner := deps.Provisioner
if provisioner == nil {
provisioner = cpstripe.NewProvisioner(
deps.Registry,
deps.Config.TenantsDir(),
deps.Docker,
deps.MagicLinks,
deps.Config.BaseURL,
deps.EmailSender,
deps.Config.EmailFrom,
deps.Config.AllowDockerlessProvisioning,
cpstripe.WithHostedEntitlementService(hostedEntitlements),
cpstripe.WithTrialActivationPrivateKey(deps.Config.TrialActivationPrivateKey),
)
}
webhookHandler := cpstripe.NewWebhookHandler(deps.Config.StripeWebhookSecret, provisioner)
mux.Handle("/api/stripe/webhook", webhookLimiter.Middleware(webhookHandler))
// Magic link verification (public, token-authenticated)
baseDomain := baseDomainFromURL(deps.Config.BaseURL)
mux.Handle("/auth/magic-link/verify", magicLinkVerifyLimiter.Middleware(http.HandlerFunc(cpauth.HandleMagicLinkVerify(deps.MagicLinks, deps.Registry, deps.Config.TenantsDir(), baseDomain, portal.PortalPagePath))))
if deps.MagicLinks != nil {
mux.Handle(portal.PortalLogoutPath, sessionAuthLimiter.Middleware(sessionAuth(cpauth.HandleLogout(deps.Registry))))
}
// Hosted Pulse Pro trial signup: public form + checkout + return completion.
trialSignupHandlers := NewTrialSignupHandlers(deps.Config, deps.EmailSender, deps.TrialSignupStore, hostedEntitlements)
hostedEntitlementHandlers := NewHostedEntitlementHandlers(hostedEntitlements)
mux.Handle("/start-pro-trial", trialSignupPageLimiter.Middleware(http.HandlerFunc(trialSignupHandlers.HandleStartProTrial)))
mux.Handle("/api/trial-signup/request-verification", trialSignupVerificationLimiter.Middleware(http.HandlerFunc(trialSignupHandlers.HandleRequestVerification)))
mux.Handle("/trial-signup/verify", trialSignupVerifyLimiter.Middleware(http.HandlerFunc(trialSignupHandlers.HandleVerifyEmail)))
mux.Handle("/api/trial-signup/checkout", trialSignupCheckoutLimiter.Middleware(http.HandlerFunc(trialSignupHandlers.HandleCheckout)))
mux.Handle("/trial-signup/complete", trialSignupCompleteLimiter.Middleware(http.HandlerFunc(trialSignupHandlers.HandleTrialSignupComplete)))
mux.Handle("/api/trial-signup/redeem", trialSignupRedeemLimiter.Middleware(http.HandlerFunc(trialSignupHandlers.HandleTrialSignupRedeem)))
mux.Handle("/api/entitlements/refresh", hostedEntitlementRefreshLimiter.Middleware(http.HandlerFunc(hostedEntitlementHandlers.HandleRefresh)))
mux.Handle("/api/trial-signup/refresh", hostedEntitlementRefreshLimiter.Middleware(http.HandlerFunc(hostedEntitlementHandlers.HandleRefresh)))
// Public commercial magic-link requests stay available for existing hosted
// accounts even while public v6 Cloud signup remains prelaunch-disabled.
publicCloudSignupHandlers := NewPublicCloudSignupHandlers(deps.Config, deps.Registry, deps.MagicLinks, deps.EmailSender)
mux.Handle("/api/public/magic-link/request", publicMagicLinkLimiter.Middleware(http.HandlerFunc(publicCloudSignupHandlers.HandlePublicMagicLinkRequest)))
// Pulse Cloud self-serve signup: public page + API checkout.
if publicCloudSignupPath != "" {
mux.Handle("/signup", publicSignupLimiter.Middleware(http.HandlerFunc(publicCloudSignupHandlers.HandleSignupPage)))
mux.Handle("/cloud/signup", publicSignupLimiter.Middleware(http.HandlerFunc(publicCloudSignupHandlers.HandleSignupPage)))
mux.Handle("/signup/complete", publicSignupLimiter.Middleware(http.HandlerFunc(publicCloudSignupHandlers.HandleSignupComplete)))
mux.Handle("/cloud/signup/complete", publicSignupLimiter.Middleware(http.HandlerFunc(publicCloudSignupHandlers.HandleSignupComplete)))
mux.Handle("/api/public/signup", publicSignupLimiter.Middleware(http.HandlerFunc(publicCloudSignupHandlers.HandlePublicSignup)))
}
// Admin API (key-authenticated)
tenantsHandler := admin.HandleListTenants(deps.Registry)
mux.Handle("/admin/tenants", adminLimiter.Middleware(adminAuth(tenantsHandler)))
mux.Handle("/admin/magic-link", adminLimiter.Middleware(adminAuth(cpauth.HandleAdminGenerateMagicLink(deps.MagicLinks, deps.Config.BaseURL, deps.EmailSender, deps.Config.EmailFrom))))
// Account membership (session + account-membership authenticated)
listMembers := account.HandleListMembers(deps.Registry)
inviteMember := account.HandleInviteMember(deps.Registry)
updateRole := account.HandleUpdateMemberRole(deps.Registry)
removeMember := account.HandleRemoveMember(deps.Registry)
membersCollection := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
listMembers(w, r)
case http.MethodPost:
accountMutationAuth(http.HandlerFunc(inviteMember)).ServeHTTP(w, r)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
member := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPatch:
accountMutationAuth(http.HandlerFunc(updateRole)).ServeHTTP(w, r)
case http.MethodDelete:
accountMutationAuth(http.HandlerFunc(removeMember)).ServeHTTP(w, r)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
accountIDFromPath := func(r *http.Request) string {
return r.PathValue("account_id")
}
accountIDFromPortalRequest := func(r *http.Request) string {
if v := strings.TrimSpace(r.URL.Query().Get("account_id")); v != "" {
return v
}
if v := strings.TrimSpace(r.Header.Get("X-Account-ID")); v != "" {
return v
}
if v := strings.TrimSpace(r.Header.Get("X-Account-Id")); v != "" {
return v
}
return ""
}
mux.Handle("/api/accounts/{account_id}/members", accountAPILimiter.Middleware(accountSessionAuth(accountIDFromPath, membersCollection)))
mux.Handle("/api/accounts/{account_id}/members/{user_id}", accountAPILimiter.Middleware(accountSessionAuth(accountIDFromPath, member)))
// Workspace management (session + account-membership authenticated)
listTenants := account.HandleListTenants(deps.Registry)
createTenant := account.HandleCreateTenant(deps.Registry, provisioner)
updateTenant := account.HandleUpdateTenant(deps.Registry)
deleteTenant := account.HandleDeleteTenant(deps.Registry, provisioner)
tenantsCollection := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
listTenants(w, r)
case http.MethodPost:
accountMutationAuth(http.HandlerFunc(createTenant)).ServeHTTP(w, r)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
tenant := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPatch:
accountMutationAuth(http.HandlerFunc(updateTenant)).ServeHTTP(w, r)
case http.MethodDelete:
accountMutationAuth(http.HandlerFunc(deleteTenant)).ServeHTTP(w, r)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
mux.Handle("/api/accounts/{account_id}/tenants", accountAPILimiter.Middleware(accountSessionAuth(accountIDFromPath, tenantsCollection)))
mux.Handle("/api/accounts/{account_id}/tenants/{tenant_id}", accountAPILimiter.Middleware(accountSessionAuth(accountIDFromPath, tenant)))
// Tenant switching handoff (session + account-membership authenticated)
handoffHandler := handoff.HandleHandoff(deps.Registry, deps.Config.TenantsDir())
mux.Handle("/api/accounts/{account_id}/tenants/{tenant_id}/handoff", accountAPILimiter.Middleware(accountSessionAuth(accountIDFromPath, handoffHandler)))
// MSP portal API (session + account-membership authenticated)
mux.Handle(portal.PortalBootstrapPath, portalAPILimiter.Middleware(sessionAuth(portal.HandlePortalBootstrapWithSignupPath(deps.MagicLinks, deps.Registry, portalCommercialLookup, publicCloudSignupPath))))
mux.Handle(portal.PortalDashboardPath, portalAPILimiter.Middleware(accountSessionAuth(accountIDFromPortalRequest, portal.HandlePortalDashboard(deps.Registry))))
mux.Handle(portal.PortalWorkspacePath, portalAPILimiter.Middleware(accountSessionAuth(accountIDFromPortalRequest, portal.HandlePortalWorkspaceDetail(deps.Registry))))
// Stripe Customer Portal redirect (session + account-membership authenticated)
billingCfg := portal.BillingPortalConfig{
StripeAPIKey: deps.Config.StripeAPIKey,
ReturnURL: buildCPURL(deps.Config.BaseURL, portal.PortalPagePath, nil),
}
mux.Handle(portal.PortalBillingPath, portalAPILimiter.Middleware(accountSessionAuth(accountIDFromPortalRequest, portal.HandleBillingPortalRedirect(deps.Registry, billingCfg))))
mux.Handle(portal.PortalCommercialProxyPath, portalAPILimiter.Middleware(sessionAuth(portal.HandleCommercialProxy(portal.CommercialProxyConfig{
BaseURL: deps.Config.LicenseServerURL,
}))))
// MSP/Cloud portal HTML page — self-authenticating (shows login form if no session)
portalPageLimiter := NewCPRateLimiter(60, time.Minute)
mux.Handle(portal.PortalPagePath, portalPageLimiter.Middleware(http.HandlerFunc(portal.HandlePortalPageWithSignupPath(deps.MagicLinks, deps.Registry, portalCommercialLookup, controlPlaneFaviconHref(), publicCloudSignupPath))))
}