diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 9bd5ef282..d518bc862 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -98,6 +98,7 @@ cloud-specific enforcement rules. 86. `internal/cloudcp/handoff/handler.go`, `internal/cloudcp/handoff/handoff.go` 87. `internal/cloudcp/stripe/grace_enforcer.go`, `internal/cloudcp/stripe/helpers.go`, `internal/cloudcp/stripe/reconciler.go`, `internal/cloudcp/stripe/webhook.go` 88. `internal/hosted/hosted_metrics.go`, `internal/hosted/reaper.go` +89. `internal/cloudcp/public_msp_signup_handlers.go` ## Shared Boundaries @@ -230,6 +231,16 @@ or other self-hosted uncapped continuity plans. tenant-runtime capacity/log retention configuration, or checkout gating through `internal/cloudcp/config.go` and `internal/cloudcp/public_cloud_signup_handlers.go` + Public MSP self-serve signup is the operator-account front door for the same + boundary and lives in `internal/cloudcp/public_msp_signup_handlers.go`. Its + per-tier price configuration (`CP_MSP_STARTER_PRICE_ID`, + `CP_MSP_GROWTH_PRICE_ID`, `CP_MSP_SCALE_PRICE_ID`) is validated in + `internal/cloudcp/config.go` against the canonical `msp_starter`, + `msp_growth`, and `msp_scale` plan versions, and the MSP signup routes stay + gated behind the same `PublicCloudSignupEnabled` flag as the individual + cloud signup front door. Checkout metadata produced by the MSP front door + must mark `account_kind=msp` so provisioning seeds an operator workspace + rather than an individual tenant. 6. Add or change the hosted account portal API, Pulse Account access/auth/session handling, task-first browser shell, maintained portal frontend/bundle, or account-scoped workspace/access/billing handoff through `internal/cloudcp/account/audit.go`, `internal/cloudcp/account/handlers.go`, `internal/cloudcp/auth/handlers.go`, `internal/cloudcp/auth/session.go`, `internal/cloudcp/portal/`, and `internal/cloudcp/routes.go` That same customer-entry boundary owns the canonical hosted Cloud handoff: public Cloud entry, secure checkout return, and returning-customer sign-in @@ -686,6 +697,19 @@ boundary: hosted Cloud/MSP checkout events may provision tenants, while self-hosted landing purchases are acknowledged and ignored rather than creating hosted containers or Stripe account mappings. +The hosted MSP offering now has a public self-serve front door alongside the +individual cloud signup page. `internal/cloudcp/public_msp_signup_handlers.go` +serves `/cloud/msp/signup`, `/cloud/msp/signup/complete`, and +`/api/public/msp/signup`, all gated behind the same `PublicCloudSignupEnabled` +flag as `/cloud/signup`, so the MSP front door stays dark until an operator +explicitly enables public signup. Each MSP tier (starter, growth, scale) is +served only when its Stripe price is configured (`CP_MSP_STARTER_PRICE_ID`, +`CP_MSP_GROWTH_PRICE_ID`, `CP_MSP_SCALE_PRICE_ID`); the signup page renders an +explicit "not open for self-serve signup yet" notice when no MSP tier price is +configured rather than offering an unbacked checkout. Checkout sessions started +from this front door carry `account_kind=msp` and `signup_source=public_msp_signup` +metadata so the provisioner seeds an isolated operator workspace. + Cloud paid readiness is materially behind architecture work. The main concern is contract coherence between pricing, entitlements, and runtime enforcement. Pulse Account portal workspace copy is part of that same readiness contract: diff --git a/internal/cloudcp/config.go b/internal/cloudcp/config.go index 68e63bb73..a6c38e6eb 100644 --- a/internal/cloudcp/config.go +++ b/internal/cloudcp/config.go @@ -55,6 +55,9 @@ type CPConfig struct { TrialSignupPriceID string // Cloud Starter (default tier) price ID CloudPowerPriceID string // Cloud Power tier price ID (optional) CloudMaxPriceID string // Cloud Max tier price ID (optional) + CloudMSPStarterPriceID string // MSP Starter tier price ID (optional) + CloudMSPGrowthPriceID string // MSP Growth tier price ID (optional) + CloudMSPScalePriceID string // MSP Scale tier price ID (optional) LicenseServerURL string LicenseAdminToken string TrialActivationPrivateKey string @@ -183,6 +186,9 @@ func LoadConfig() (*CPConfig, error) { TrialSignupPriceID: strings.TrimSpace(os.Getenv("CP_TRIAL_SIGNUP_PRICE_ID")), CloudPowerPriceID: strings.TrimSpace(os.Getenv("CP_CLOUD_POWER_PRICE_ID")), CloudMaxPriceID: strings.TrimSpace(os.Getenv("CP_CLOUD_MAX_PRICE_ID")), + CloudMSPStarterPriceID: strings.TrimSpace(os.Getenv("CP_MSP_STARTER_PRICE_ID")), + CloudMSPGrowthPriceID: strings.TrimSpace(os.Getenv("CP_MSP_GROWTH_PRICE_ID")), + CloudMSPScalePriceID: strings.TrimSpace(os.Getenv("CP_MSP_SCALE_PRICE_ID")), LicenseServerURL: envOrDefault("PULSE_LICENSE_SERVER_URL", "https://license.pulserelay.pro"), LicenseAdminToken: strings.TrimSpace(os.Getenv("PULSE_LICENSE_ADMIN_TOKEN")), TrialActivationPrivateKey: strings.TrimSpace(os.Getenv("CP_TRIAL_ACTIVATION_PRIVATE_KEY")), @@ -323,6 +329,15 @@ func (c *CPConfig) validate() error { if err := validateCloudStripePriceID(c.Environment, c.StripeAPIKey, "CP_CLOUD_MAX_PRICE_ID", c.CloudMaxPriceID, "cloud_max"); err != nil { return err } + if err := validateCloudStripePriceID(c.Environment, c.StripeAPIKey, "CP_MSP_STARTER_PRICE_ID", c.CloudMSPStarterPriceID, "msp_starter"); err != nil { + return err + } + if err := validateCloudStripePriceID(c.Environment, c.StripeAPIKey, "CP_MSP_GROWTH_PRICE_ID", c.CloudMSPGrowthPriceID, "msp_growth"); err != nil { + return err + } + if err := validateCloudStripePriceID(c.Environment, c.StripeAPIKey, "CP_MSP_SCALE_PRICE_ID", c.CloudMSPScalePriceID, "msp_scale"); err != nil { + return err + } if strings.TrimSpace(c.LicenseServerURL) == "" && strings.TrimSpace(c.LicenseAdminToken) != "" { return fmt.Errorf("PULSE_LICENSE_SERVER_URL is required when PULSE_LICENSE_ADMIN_TOKEN is configured") } diff --git a/internal/cloudcp/config_test.go b/internal/cloudcp/config_test.go index 7524e4d89..fc84b9655 100644 --- a/internal/cloudcp/config_test.go +++ b/internal/cloudcp/config_test.go @@ -386,6 +386,48 @@ func TestLoadConfig_AcceptsCanonicalCloudSignupPriceIDs(t *testing.T) { } } +func TestLoadConfig_AcceptsCanonicalMSPSignupPriceIDs(t *testing.T) { + setRequiredCPEnv(t) + setTrialSigningEnv(t) + t.Setenv("CP_ENV", "production") + t.Setenv("STRIPE_API_KEY", "sk_live_123") + t.Setenv("CP_MSP_STARTER_PRICE_ID", "price_1T5kgTBrHBocJIGHjOs15LI2") + t.Setenv("CP_MSP_GROWTH_PRICE_ID", "price_1T5kgVBrHBocJIGHulNsCTb1") + t.Setenv("CP_MSP_SCALE_PRICE_ID", "price_1T5kgWBrHBocJIGHo40iFeRd") + + cfg, err := LoadConfig() + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + if cfg.CloudMSPStarterPriceID != "price_1T5kgTBrHBocJIGHjOs15LI2" { + t.Fatalf("CloudMSPStarterPriceID=%q want canonical msp_starter price", cfg.CloudMSPStarterPriceID) + } + if cfg.CloudMSPGrowthPriceID != "price_1T5kgVBrHBocJIGHulNsCTb1" { + t.Fatalf("CloudMSPGrowthPriceID=%q want canonical msp_growth price", cfg.CloudMSPGrowthPriceID) + } + if cfg.CloudMSPScalePriceID != "price_1T5kgWBrHBocJIGHo40iFeRd" { + t.Fatalf("CloudMSPScalePriceID=%q want canonical msp_scale price", cfg.CloudMSPScalePriceID) + } +} + +func TestLoadConfig_RejectsNonMSPStarterPriceIDInProductionCatalog(t *testing.T) { + setRequiredCPEnv(t) + setTrialSigningEnv(t) + t.Setenv("CP_ENV", "production") + t.Setenv("STRIPE_API_KEY", "sk_live_123") + // A canonical cloud_power price is a valid Stripe price but maps to the + // wrong plan version for the MSP Starter slot, so config must fail closed. + t.Setenv("CP_MSP_STARTER_PRICE_ID", "price_1T5kg2BrHBocJIGHmkoF0zXY") + + _, err := LoadConfig() + if err == nil { + t.Fatal("expected error for non-msp_starter CP_MSP_STARTER_PRICE_ID") + } + if !strings.Contains(err.Error(), "CP_MSP_STARTER_PRICE_ID must map to the canonical msp_starter Stripe price") { + t.Fatalf("unexpected error: %v", err) + } +} + func TestLoadConfig_AllowsMissingTrialSignupPriceWhenPublicCloudSignupDisabled(t *testing.T) { setRequiredCPEnv(t) setTrialSigningEnv(t) diff --git a/internal/cloudcp/public_cloud_signup_handlers.go b/internal/cloudcp/public_cloud_signup_handlers.go index 6260ea85c..82c509849 100644 --- a/internal/cloudcp/public_cloud_signup_handlers.go +++ b/internal/cloudcp/public_cloud_signup_handlers.go @@ -377,6 +377,34 @@ func (h *PublicCloudSignupHandlers) HandleSignupComplete(w http.ResponseWriter, } func (h *PublicCloudSignupHandlers) HandlePublicSignup(w http.ResponseWriter, r *http.Request) { + h.servePublicSignupCheckout(w, r, + "Invalid plan tier. Must be one of: starter, power, max", + "public cloud signup API checkout creation failed", + fmt.Sprintf("Checkout session created. Continue in Stripe to start your %d-day Pulse Cloud trial and provision your workspace.", publicCloudTrialDays), + func(tierRaw string) (bool, bool, func(email, orgName string) (string, error)) { + tier, ok := parseCloudTier(tierRaw) + if !ok { + return false, false, nil + } + _, available := h.priceIDForTier(tier) + return true, available, func(email, orgName string) (string, error) { + return h.createCheckout(email, orgName, tier) + } + }, + ) +} + +// servePublicSignupCheckout runs the shared method/decode/validate/checkout +// skeleton for the public Cloud and MSP signup JSON endpoints. resolve supplies +// the per-path tier parsing, price availability, and checkout creation. +func (h *PublicCloudSignupHandlers) servePublicSignupCheckout( + w http.ResponseWriter, + r *http.Request, + invalidTierMessage string, + checkoutLogMsg string, + successMessage string, + resolve func(tierRaw string) (tierValid bool, available bool, create func(email, orgName string) (string, error)), +) { if r.Method != http.MethodPost { writePublicSignupError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed") return @@ -389,37 +417,37 @@ func (h *PublicCloudSignupHandlers) HandlePublicSignup(w http.ResponseWriter, r return } - req.Email = strings.TrimSpace(req.Email) - req.OrgName = strings.TrimSpace(req.OrgName) + email := strings.TrimSpace(req.Email) + orgName := strings.TrimSpace(req.OrgName) - tier, tierOK := parseCloudTier(req.Tier) - if !tierOK { - writePublicSignupError(w, http.StatusBadRequest, "invalid_tier", "Invalid plan tier. Must be one of: starter, power, max") + tierValid, available, create := resolve(req.Tier) + if !tierValid { + writePublicSignupError(w, http.StatusBadRequest, "invalid_tier", invalidTierMessage) return } - if !isValidCloudSignupEmail(req.Email) { + if !isValidCloudSignupEmail(email) { writePublicSignupError(w, http.StatusBadRequest, "invalid_email", "Invalid email format") return } - if !isValidCloudSignupOrgName(req.OrgName) { + if !isValidCloudSignupOrgName(orgName) { writePublicSignupError(w, http.StatusBadRequest, "invalid_org_name", "Invalid organization name") return } - if _, avail := h.priceIDForTier(tier); !avail { + if !available { writePublicSignupError(w, http.StatusBadRequest, "tier_unavailable", "The selected plan tier is not currently available") return } - checkoutURL, err := h.createCheckout(req.Email, req.OrgName, tier) + checkoutURL, err := create(email, orgName) if err != nil { - log.Warn().Err(err).Str("email", req.Email).Msg("public cloud signup API checkout creation failed") + log.Warn().Err(err).Str("email", email).Msg(checkoutLogMsg) writePublicSignupError(w, http.StatusBadGateway, "checkout_failed", "Unable to create checkout session") return } writePublicSignupJSON(w, http.StatusCreated, map[string]any{ "checkout_url": checkoutURL, - "message": fmt.Sprintf("Checkout session created. Continue in Stripe to start your %d-day Pulse Cloud trial and provision your workspace.", publicCloudTrialDays), + "message": successMessage, }) } @@ -501,9 +529,6 @@ func (h *PublicCloudSignupHandlers) createCheckout(email, orgName string, tier c if h.cfg == nil { return "", fmt.Errorf("control plane config is missing") } - if strings.TrimSpace(h.cfg.StripeAPIKey) == "" { - return "", fmt.Errorf("stripe api key not configured") - } priceID, ok := h.priceIDForTier(tier) if !ok || priceID == "" { return "", fmt.Errorf("price id not configured for tier %q", tier) @@ -512,7 +537,6 @@ func (h *PublicCloudSignupHandlers) createCheckout(email, orgName string, tier c return "", err } - stripe.Key = strings.TrimSpace(h.cfg.StripeAPIKey) successURL := buildCPURL(h.cfg.BaseURL, canonicalPublicCloudSignupPath+"/complete", nil) cancelURL := buildCPURL(h.cfg.BaseURL, canonicalPublicCloudSignupPath, url.Values{ "cancelled": {"1"}, @@ -520,6 +544,25 @@ func (h *PublicCloudSignupHandlers) createCheckout(email, orgName string, tier c "org_name": {orgName}, "tier": {string(tier)}, }) + return h.createTrialCheckoutSession(email, priceID, successURL, cancelURL, h.buildCheckoutMetadata(priceID, orgName)) +} + +// createTrialCheckoutSession builds a subscription-mode Stripe Checkout session +// for a single recurring price with the standard public-signup trial, and +// returns the redirect URL. Shared by the individual Cloud and MSP signup paths; +// callers own price resolution, success/cancel URLs, and metadata. +func (h *PublicCloudSignupHandlers) createTrialCheckoutSession(email, priceID, successURL, cancelURL string, metadata map[string]string) (string, error) { + if h.cfg == nil { + return "", fmt.Errorf("control plane config is missing") + } + if strings.TrimSpace(h.cfg.StripeAPIKey) == "" { + return "", fmt.Errorf("stripe api key not configured") + } + if strings.TrimSpace(priceID) == "" { + return "", fmt.Errorf("price id not configured") + } + + stripe.Key = strings.TrimSpace(h.cfg.StripeAPIKey) params := &stripe.CheckoutSessionParams{ Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)), SuccessURL: stripe.String(successURL), @@ -535,7 +578,7 @@ func (h *PublicCloudSignupHandlers) createCheckout(email, orgName string, tier c SubscriptionData: &stripe.CheckoutSessionSubscriptionDataParams{ TrialPeriodDays: stripe.Int64(publicCloudTrialDays), }, - Metadata: h.buildCheckoutMetadata(priceID, orgName), + Metadata: metadata, } session, err := h.createCheckoutSession(params) if err != nil { diff --git a/internal/cloudcp/public_msp_signup_handlers.go b/internal/cloudcp/public_msp_signup_handlers.go new file mode 100644 index 000000000..9b3963673 --- /dev/null +++ b/internal/cloudcp/public_msp_signup_handlers.go @@ -0,0 +1,399 @@ +package cloudcp + +import ( + "fmt" + "html/template" + "net/http" + "net/url" + "strings" + + "github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/cpsec" + "github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/registry" + pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing" + "github.com/rs/zerolog/log" +) + +const canonicalPublicMSPSignupPath = "/cloud/msp/signup" + +// mspTier represents a hosted MSP plan tier for public signup. The three tiers +// mirror the canonical MSP plan-version ladder in pkg/licensing (msp_starter / +// msp_growth / msp_scale), each capped at a different number of client +// workspaces. +type mspTier string + +const ( + mspTierStarter mspTier = "starter" + mspTierGrowth mspTier = "growth" + mspTierScale mspTier = "scale" +) + +var validMSPTiers = map[mspTier]bool{ + mspTierStarter: true, + mspTierGrowth: true, + mspTierScale: true, +} + +// parseMSPTier normalizes a tier string from user input. Returns mspTierStarter +// if the input is empty (default). Returns ("", false) if the input is a +// non-empty but unrecognized tier. +func parseMSPTier(raw string) (mspTier, bool) { + t := mspTier(strings.ToLower(strings.TrimSpace(raw))) + if t == "" { + return mspTierStarter, true + } + if validMSPTiers[t] { + return t, true + } + return "", false +} + +func expectedPlanVersionForMSPTier(tier mspTier) string { + switch tier { + case mspTierStarter: + return "msp_starter" + case mspTierGrowth: + return "msp_growth" + case mspTierScale: + return "msp_scale" + default: + return "" + } +} + +// priceIDForMSPTier returns the configured Stripe price ID for the given MSP +// tier. Returns ("", false) if the tier's price ID is not configured. An MSP +// tier with no configured price ID is treated as not offered, which is how the +// front door stays inert until Richard sets the price IDs in CP env. +func (h *PublicCloudSignupHandlers) priceIDForMSPTier(tier mspTier) (string, bool) { + if h.cfg == nil { + return "", false + } + switch tier { + case mspTierStarter: + id := strings.TrimSpace(h.cfg.CloudMSPStarterPriceID) + return id, id != "" + case mspTierGrowth: + id := strings.TrimSpace(h.cfg.CloudMSPGrowthPriceID) + return id, id != "" + case mspTierScale: + id := strings.TrimSpace(h.cfg.CloudMSPScalePriceID) + return id, id != "" + default: + return "", false + } +} + +func (h *PublicCloudSignupHandlers) hasMSPTier(tier mspTier) bool { + _, ok := h.priceIDForMSPTier(tier) + return ok +} + +// defaultMSPTier returns the lowest configured MSP tier, preferring +// starter → growth → scale. The bool is false when no MSP tier is configured. +func (h *PublicCloudSignupHandlers) defaultMSPTier() (mspTier, bool) { + for _, t := range []mspTier{mspTierStarter, mspTierGrowth, mspTierScale} { + if h.hasMSPTier(t) { + return t, true + } + } + return "", false +} + +func validatePublicMSPSignupPriceID(tier mspTier, priceID string) error { + wantPlanVersion := expectedPlanVersionForMSPTier(tier) + if wantPlanVersion == "" { + return fmt.Errorf("unsupported msp tier %q", tier) + } + if err := validateCloudStripePriceID("production", "", "public msp signup price", priceID, wantPlanVersion); err != nil { + return err + } + return nil +} + +var publicMSPSignupPageTemplate = template.Must(template.New("public-msp-signup-page").Parse(` + +
+ + +Run Pulse for multiple clients from one hosted operator account. Each client gets an isolated workspace; you manage them all from the MSP portal. Stripe checkout securely collects a payment method, but the subscription starts with a {{.TrialDays}}-day trial and no upfront charge.
+ {{if .ErrorMessage}}After checkout, we will email a Pulse Account sign-in link so you can open your MSP portal.
+Your {{.TrialDays}}-day Pulse Cloud for MSPs trial checkout completed. Pulse Cloud is provisioning your MSP operator account.
+Watch your inbox for a Pulse Account sign-in link. That link lands in Pulse Account, where you can open the MSP portal, add client workspaces, and continue setup.
+