mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-08-25 08:52:06 +00:00
1540 lines
61 KiB
Go
1540 lines
61 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"html"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/crypto"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/mock"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/securityutil"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
const (
|
|
licensePurchaseStartPath = "/auth/license-purchase-start"
|
|
licensePurchaseActivationPath = "/auth/license-purchase-activate"
|
|
licensePurchaseSessionIDField = "session_id"
|
|
licensePurchaseReturnTokenField = "purchase_return_token"
|
|
pulseAccountUpgradeService = "upgrade"
|
|
pulseAccountPortalFeatureQueryParam = "feature"
|
|
pulseAccountPortalHandoffIDField = "portal_handoff_id"
|
|
pulseAccountPortalHandoffURLQueryParam = "purchase_handoff_url"
|
|
pulseAccountPortalServiceQueryParam = "service"
|
|
pulseAccountPortalReturnTokenQueryParam = "purchase_return_token"
|
|
selfHostedBillingPurchaseQueryParam = "purchase"
|
|
selfHostedBillingPurchaseActivated = "activated"
|
|
selfHostedBillingPurchaseCancelled = "cancelled"
|
|
selfHostedBillingPurchaseExpired = "expired"
|
|
selfHostedBillingPurchaseFailed = "failed"
|
|
selfHostedBillingPurchaseUnavailable = "unavailable"
|
|
purchaseReturnKeyPurpose = "pulse-license-purchase-return"
|
|
)
|
|
|
|
func wantsMockFixturesFromEnv() bool {
|
|
return strings.EqualFold(strings.TrimSpace(os.Getenv("PULSE_MOCK_MODE")), "true")
|
|
}
|
|
|
|
// LicenseHandlers handles license management API endpoints.
|
|
type LicenseHandlers struct {
|
|
mtPersistence *config.MultiTenantPersistence
|
|
hostedMode bool
|
|
cfg *config.Config
|
|
services sync.Map // map[string]*licenseService
|
|
purchaseReturnRedemptions *purchaseReturnRedemptionStore
|
|
monitor *monitoring.Monitor
|
|
mtMonitor *monitoring.MultiTenantMonitor
|
|
hostedLeaseRefresh sync.Map // map[string]*hostedEntitlementRefreshLoop
|
|
runtimeVersion string
|
|
runtimeIdentity runtimeIdentityModel
|
|
|
|
// legacyExchangeRetries tracks orgs with a background v5→v6 exchange
|
|
// retry loop in flight so only one loop runs per org.
|
|
legacyExchangeRetries sync.Map // map[string]struct{}
|
|
// legacyExchangeRetrySchedule overrides the retry backoff in tests.
|
|
legacyExchangeRetrySchedule []time.Duration
|
|
// commercialMigrationNow overrides migration timing in tests.
|
|
commercialMigrationNow func() time.Time
|
|
}
|
|
|
|
// NewLicenseHandlers creates a new license handlers instance.
|
|
|
|
func NewLicenseHandlers(mtp *config.MultiTenantPersistence, hostedMode bool, cfgs ...*config.Config) *LicenseHandlers {
|
|
var cfg *config.Config
|
|
if len(cfgs) > 0 {
|
|
cfg = cfgs[0]
|
|
}
|
|
|
|
var purchaseReturnRedemptions *purchaseReturnRedemptionStore
|
|
if mtp != nil {
|
|
purchaseReturnRedemptions = &purchaseReturnRedemptionStore{configDir: mtp.BaseDataDir()}
|
|
}
|
|
|
|
return &LicenseHandlers{
|
|
mtPersistence: mtp,
|
|
hostedMode: hostedMode,
|
|
cfg: cfg,
|
|
purchaseReturnRedemptions: purchaseReturnRedemptions,
|
|
}
|
|
}
|
|
|
|
// SetMonitors wires the monitors used for monitored-system counting in entitlement usage.
|
|
func (h *LicenseHandlers) SetMonitors(monitor *monitoring.Monitor, mtMonitor *monitoring.MultiTenantMonitor) {
|
|
if h == nil {
|
|
return
|
|
}
|
|
h.monitor = monitor
|
|
h.mtMonitor = mtMonitor
|
|
}
|
|
|
|
func (h *LicenseHandlers) SetConfig(cfg *config.Config) {
|
|
if h == nil || cfg == nil {
|
|
return
|
|
}
|
|
h.cfg = cfg
|
|
}
|
|
|
|
func (h *LicenseHandlers) SetRuntimeVersion(version string) {
|
|
if h == nil {
|
|
return
|
|
}
|
|
h.runtimeVersion = strings.TrimSpace(version)
|
|
}
|
|
|
|
func (h *LicenseHandlers) SetRuntimeIdentity(identity runtimeIdentityModel) {
|
|
if h == nil {
|
|
return
|
|
}
|
|
h.runtimeIdentity = normalizeRuntimeIdentityFromLicensing(identity)
|
|
}
|
|
|
|
func (h *LicenseHandlers) currentRuntimeIdentity() runtimeIdentityModel {
|
|
if h == nil {
|
|
return communityRuntimeIdentityFromLicensing()
|
|
}
|
|
return normalizeRuntimeIdentityFromLicensing(h.runtimeIdentity)
|
|
}
|
|
|
|
// StopAllBackgroundLoops stops grant refresh and revocation poll loops for all tenant services.
|
|
// Called during server shutdown to ensure clean goroutine termination.
|
|
func (h *LicenseHandlers) StopAllBackgroundLoops() {
|
|
if h == nil {
|
|
return
|
|
}
|
|
h.hostedLeaseRefresh.Range(func(key, value any) bool {
|
|
if orgID, ok := key.(string); ok {
|
|
h.stopHostedEntitlementRefreshLoop(orgID)
|
|
}
|
|
return true
|
|
})
|
|
h.legacyExchangeRetries.Range(func(key, _ any) bool {
|
|
if orgID, ok := key.(string); ok {
|
|
h.stopLegacyExchangeRetry(orgID)
|
|
}
|
|
return true
|
|
})
|
|
h.services.Range(func(_, value any) bool {
|
|
if svc, ok := value.(*licenseService); ok {
|
|
svc.StopGrantRefresh()
|
|
svc.StopInstallationStatusPoll()
|
|
}
|
|
return true
|
|
})
|
|
}
|
|
|
|
func publicBaseURLForRequest(r *http.Request, cfg *config.Config) string {
|
|
baseURL := ""
|
|
if cfg != nil {
|
|
baseURL = strings.TrimSpace(cfg.PublicURL)
|
|
}
|
|
if baseURL == "" && r != nil {
|
|
scheme := "http"
|
|
if xfProto := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); xfProto != "" {
|
|
scheme = strings.ToLower(strings.TrimSpace(strings.Split(xfProto, ",")[0]))
|
|
} else if r.TLS != nil {
|
|
scheme = "https"
|
|
}
|
|
|
|
host := strings.TrimSpace(r.Host)
|
|
if xfHost := strings.TrimSpace(r.Header.Get("X-Forwarded-Host")); xfHost != "" {
|
|
host = strings.TrimSpace(strings.Split(xfHost, ",")[0])
|
|
}
|
|
if host == "" {
|
|
return ""
|
|
}
|
|
baseURL = scheme + "://" + host
|
|
}
|
|
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
|
return baseURL
|
|
}
|
|
|
|
func securePublicCallbackBaseURLForRequest(r *http.Request, cfg *config.Config) string {
|
|
baseURL := publicBaseURLForRequest(r, cfg)
|
|
if baseURL == "" {
|
|
return ""
|
|
}
|
|
|
|
parsed, err := securityutil.NormalizePulseHTTPBaseURL(baseURL)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
if parsed.Scheme == "http" && (r == nil || !isDirectLoopbackRequest(r)) {
|
|
return ""
|
|
}
|
|
|
|
return strings.TrimRight(parsed.String(), "/")
|
|
}
|
|
|
|
func licensePurchaseCallbackURLForRequest(r *http.Request, cfg *config.Config) string {
|
|
baseURL := securePublicCallbackBaseURLForRequest(r, cfg)
|
|
if baseURL == "" {
|
|
return ""
|
|
}
|
|
return baseURL + licensePurchaseActivationPath
|
|
}
|
|
|
|
func purchaseReturnExpectedHost(r *http.Request, cfg *config.Config) string {
|
|
if cfg != nil && strings.TrimSpace(cfg.PublicURL) != "" {
|
|
return normalizeHostForTrial(cfg.PublicURL)
|
|
}
|
|
return normalizeHostForTrial(publicBaseURLForRequest(r, nil))
|
|
}
|
|
|
|
func parseHostedCommercialURL(raw string) (*url.URL, error) {
|
|
parsed, err := securityutil.NormalizeAbsoluteHTTPURL(strings.TrimSpace(raw))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
switch strings.ToLower(parsed.Scheme) {
|
|
case "https":
|
|
parsed.Scheme = "https"
|
|
case "http":
|
|
if !securityutil.IsLoopbackHost(parsed.Hostname()) {
|
|
return nil, fmt.Errorf("URL %q must use https unless host is loopback", raw)
|
|
}
|
|
parsed.Scheme = "http"
|
|
default:
|
|
return nil, fmt.Errorf("URL %q has unsupported scheme %q", raw, parsed.Scheme)
|
|
}
|
|
|
|
parsed.Host = strings.ToLower(parsed.Host)
|
|
parsed.Fragment = ""
|
|
return parsed, nil
|
|
}
|
|
|
|
func (h *LicenseHandlers) purchaseReturnDataDir() string {
|
|
if h == nil {
|
|
return ""
|
|
}
|
|
if h.mtPersistence != nil {
|
|
return h.mtPersistence.BaseDataDir()
|
|
}
|
|
if h.cfg != nil {
|
|
return strings.TrimSpace(h.cfg.DataPath)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (h *LicenseHandlers) purchaseReturnSigningKey() ([]byte, error) {
|
|
dataDir := h.purchaseReturnDataDir()
|
|
if strings.TrimSpace(dataDir) == "" {
|
|
return nil, fmt.Errorf("purchase return data path is unavailable")
|
|
}
|
|
cryptoManager, err := crypto.NewCryptoManagerAt(dataDir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load purchase return crypto manager: %w", err)
|
|
}
|
|
signingKey, err := cryptoManager.DeriveKey(purchaseReturnKeyPurpose, 32)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("derive purchase return signing key: %w", err)
|
|
}
|
|
return signingKey, nil
|
|
}
|
|
|
|
func (h *LicenseHandlers) purchaseReturnRedemptionStore() *purchaseReturnRedemptionStore {
|
|
if h == nil {
|
|
return nil
|
|
}
|
|
if h.purchaseReturnRedemptions != nil {
|
|
return h.purchaseReturnRedemptions
|
|
}
|
|
dataDir := h.purchaseReturnDataDir()
|
|
if strings.TrimSpace(dataDir) == "" {
|
|
return nil
|
|
}
|
|
return &purchaseReturnRedemptionStore{configDir: dataDir}
|
|
}
|
|
|
|
func pulseAccountUpgradeURLForRequest(portalHandoffID string, query url.Values) (string, error) {
|
|
portalURL := strings.TrimSpace(pulseAccountPortalURLFromLicensing(""))
|
|
if portalURL == "" {
|
|
return "", fmt.Errorf("pulse account portal url is unavailable")
|
|
}
|
|
|
|
parsed, err := url.Parse(portalURL)
|
|
if err != nil {
|
|
return "", fmt.Errorf("parse pulse account portal url: %w", err)
|
|
}
|
|
|
|
params := parsed.Query()
|
|
for key, values := range query {
|
|
switch key {
|
|
case pulseAccountPortalFeatureQueryParam,
|
|
pulseAccountPortalHandoffIDField,
|
|
pulseAccountPortalHandoffURLQueryParam,
|
|
pulseAccountPortalServiceQueryParam,
|
|
pulseAccountPortalReturnTokenQueryParam,
|
|
"return_url",
|
|
"checkout",
|
|
"session_id":
|
|
continue
|
|
}
|
|
for _, value := range values {
|
|
normalizedValue := strings.TrimSpace(value)
|
|
if normalizedValue == "" {
|
|
continue
|
|
}
|
|
params.Set(key, normalizedValue)
|
|
}
|
|
}
|
|
|
|
params.Set(pulseAccountPortalHandoffIDField, strings.TrimSpace(portalHandoffID))
|
|
parsed.RawQuery = params.Encode()
|
|
return parsed.String(), nil
|
|
}
|
|
|
|
func publicAbsoluteURLForPath(r *http.Request, cfg *config.Config, path string) (string, error) {
|
|
baseURL := securePublicCallbackBaseURLForRequest(r, cfg)
|
|
if baseURL == "" {
|
|
return "", fmt.Errorf("public base url is unavailable")
|
|
}
|
|
base, err := url.Parse(baseURL)
|
|
if err != nil || base == nil {
|
|
return "", fmt.Errorf("parse public base url: %w", err)
|
|
}
|
|
relative, err := url.Parse(strings.TrimSpace(path))
|
|
if err != nil || relative == nil {
|
|
return "", fmt.Errorf("parse relative public path: %w", err)
|
|
}
|
|
return base.ResolveReference(relative).String(), nil
|
|
}
|
|
|
|
func licensePurchaseActivationTemplateURL(returnURL, returnToken string) (string, error) {
|
|
parsed, err := url.Parse(strings.TrimSpace(returnURL))
|
|
if err != nil || parsed == nil {
|
|
return "", fmt.Errorf("parse purchase activation return url: %w", err)
|
|
}
|
|
query := parsed.Query()
|
|
query.Set(licensePurchaseReturnTokenField, strings.TrimSpace(returnToken))
|
|
query.Set(licensePurchaseSessionIDField, "{CHECKOUT_SESSION_ID}")
|
|
parsed.RawQuery = query.Encode()
|
|
return parsed.String(), nil
|
|
}
|
|
|
|
func normalizeSelfHostedPurchaseFeature(feature string) string {
|
|
switch strings.TrimSpace(feature) {
|
|
case "":
|
|
return ""
|
|
case "max_monitored_systems", "self_hosted_plan":
|
|
return "self_hosted_plan"
|
|
default:
|
|
return strings.TrimSpace(feature)
|
|
}
|
|
}
|
|
|
|
func licensePurchaseActivationRedirectPath(feature, purchaseResult string) string {
|
|
normalizedFeature := normalizeSelfHostedPurchaseFeature(feature)
|
|
query := url.Values{}
|
|
switch normalizedFeature {
|
|
case "self_hosted_plan":
|
|
query.Set("intent", "self_hosted_plan")
|
|
}
|
|
switch strings.TrimSpace(purchaseResult) {
|
|
case selfHostedBillingPurchaseActivated,
|
|
selfHostedBillingPurchaseCancelled,
|
|
selfHostedBillingPurchaseExpired,
|
|
selfHostedBillingPurchaseFailed,
|
|
selfHostedBillingPurchaseUnavailable:
|
|
query.Set(selfHostedBillingPurchaseQueryParam, strings.TrimSpace(purchaseResult))
|
|
}
|
|
encoded := query.Encode()
|
|
if encoded == "" {
|
|
return "/settings/system/billing/plan"
|
|
}
|
|
return "/settings/system/billing/plan?" + encoded
|
|
}
|
|
|
|
func writeLicensePurchaseActivationFailurePage(
|
|
w http.ResponseWriter,
|
|
statusCode int,
|
|
feature string,
|
|
purchaseResult string,
|
|
message string,
|
|
) {
|
|
if statusCode < http.StatusBadRequest {
|
|
statusCode = http.StatusBadRequest
|
|
}
|
|
redirectPath := licensePurchaseActivationRedirectPath(feature, purchaseResult)
|
|
escapedMessage := html.EscapeString(strings.TrimSpace(message))
|
|
if escapedMessage == "" {
|
|
escapedMessage = "Purchase activation could not be completed."
|
|
}
|
|
escapedLink := html.EscapeString(redirectPath)
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(statusCode)
|
|
_, _ = fmt.Fprintf(
|
|
w,
|
|
"<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>Plan activation</title></head><body><main style=\"max-width:32rem;margin:3rem auto;padding:0 1rem;font:16px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif\"><h1 style=\"margin-bottom:0.5rem\">Plan activation needs attention</h1><p id=\"purchase-return-status\" style=\"margin-bottom:1rem\">%s</p><p><a href=\"%s\">Open Plans</a></p></main><script>(function(){var redirectPath=%q;var statusEl=document.getElementById('purchase-return-status');var redirectedOriginalTab=false;try{if(window.opener&&!window.opener.closed){redirectedOriginalTab=true;window.opener.postMessage({type:'pulse-license-purchase-return',redirectPath:redirectPath,result:%q},window.location.origin);window.opener.location.assign(redirectPath);}}catch(_){redirectedOriginalTab=false;}if(redirectedOriginalTab){setTimeout(function(){try{window.close();}catch(_){ }if(statusEl){statusEl.textContent='The Plans page has been reopened in the original tab. You can close this window if it stays open.';}},75);return;}if(statusEl){statusEl.textContent='Returning to Plans.';}setTimeout(function(){window.location.replace(redirectPath);},150);}());</script></body></html>",
|
|
escapedMessage,
|
|
escapedLink,
|
|
redirectPath,
|
|
strings.TrimSpace(purchaseResult),
|
|
)
|
|
}
|
|
|
|
func writeLicensePurchaseStartFailurePage(
|
|
w http.ResponseWriter,
|
|
statusCode int,
|
|
feature string,
|
|
message string,
|
|
) {
|
|
if statusCode < http.StatusBadRequest {
|
|
statusCode = http.StatusBadRequest
|
|
}
|
|
redirectPath := licensePurchaseActivationRedirectPath(feature, selfHostedBillingPurchaseUnavailable)
|
|
escapedMessage := html.EscapeString(strings.TrimSpace(message))
|
|
if escapedMessage == "" {
|
|
escapedMessage = "Pulse could not open Pulse Account right now."
|
|
}
|
|
escapedLink := html.EscapeString(redirectPath)
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(statusCode)
|
|
_, _ = fmt.Fprintf(
|
|
w,
|
|
"<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>Pulse Account unavailable</title></head><body><main style=\"max-width:32rem;margin:3rem auto;padding:0 1rem;font:16px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif\"><h1 style=\"margin-bottom:0.5rem\">Pulse Account unavailable</h1><p id=\"purchase-start-status\" style=\"margin-bottom:1rem\">%s</p><p><a href=\"%s\">Return to Plans</a></p></main><script>(function(){var redirectPath=%q;var statusEl=document.getElementById('purchase-start-status');var redirectedOriginalTab=false;try{if(window.opener&&!window.opener.closed){redirectedOriginalTab=true;window.opener.postMessage({type:'pulse-license-purchase-start-unavailable',redirectPath:redirectPath,result:%q},window.location.origin);window.opener.location.assign(redirectPath);}}catch(_){redirectedOriginalTab=false;}if(redirectedOriginalTab){setTimeout(function(){try{window.close();}catch(_){ }if(statusEl){statusEl.textContent='The Plans page has been reopened in the original tab. You can close this window if it stays open.';}},75);return;}if(statusEl){statusEl.textContent='Returning to Plans.';}setTimeout(function(){window.location.replace(redirectPath);},150);}());</script></body></html>",
|
|
escapedMessage,
|
|
escapedLink,
|
|
redirectPath,
|
|
selfHostedBillingPurchaseUnavailable,
|
|
)
|
|
}
|
|
|
|
func writeLicensePurchaseActivationSuccessPage(w http.ResponseWriter, feature string) {
|
|
redirectPath := licensePurchaseActivationRedirectPath(feature, selfHostedBillingPurchaseActivated)
|
|
escapedLink := html.EscapeString(redirectPath)
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = fmt.Fprintf(
|
|
w,
|
|
"<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>Plan activated</title></head><body><main style=\"max-width:32rem;margin:3rem auto;padding:0 1rem;font:16px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif\"><h1 style=\"margin-bottom:0.5rem\">Plan activated</h1><p id=\"purchase-return-status\" style=\"margin-bottom:1rem\">Pulse is refreshing Plans in the original tab.</p><p><a href=\"%s\">Return to Plans</a></p></main><script>(function(){var redirectPath=%q;var statusEl=document.getElementById('purchase-return-status');var redirectedOriginalTab=false;try{if(window.opener&&!window.opener.closed){redirectedOriginalTab=true;window.opener.postMessage({type:'pulse-license-purchase-activated',redirectPath:redirectPath},window.location.origin);window.opener.location.assign(redirectPath);}}catch(_){redirectedOriginalTab=false;}if(redirectedOriginalTab){setTimeout(function(){try{window.close();}catch(_){ }if(statusEl){statusEl.textContent='Your plan is ready. You can close this tab if it stays open.';}},75);return;}if(statusEl){statusEl.textContent='Your plan is ready. Returning to Plans.';}setTimeout(function(){window.location.replace(redirectPath);},75);}());</script></body></html>",
|
|
escapedLink,
|
|
redirectPath,
|
|
)
|
|
}
|
|
|
|
func writeLicensePurchaseActivationContinuePage(
|
|
w http.ResponseWriter,
|
|
sessionID string,
|
|
portalHandoffID string,
|
|
feature string,
|
|
returnToken string,
|
|
) {
|
|
escapedFeature := html.EscapeString(feature)
|
|
escapedReturnToken := html.EscapeString(returnToken)
|
|
escapedSessionID := html.EscapeString(sessionID)
|
|
escapedPortalHandoffID := html.EscapeString(strings.TrimSpace(portalHandoffID))
|
|
featureInput := ""
|
|
if strings.TrimSpace(feature) != "" {
|
|
featureInput = fmt.Sprintf("<input type=\"hidden\" name=\"feature\" value=\"%s\">", escapedFeature)
|
|
}
|
|
portalHandoffInput := ""
|
|
if strings.TrimSpace(portalHandoffID) != "" {
|
|
portalHandoffInput = fmt.Sprintf("<input type=\"hidden\" name=\"%s\" value=\"%s\">", pulseAccountPortalHandoffIDField, escapedPortalHandoffID)
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = fmt.Fprintf(
|
|
w,
|
|
"<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>Finalizing plan upgrade</title></head><body><main style=\"max-width:32rem;margin:3rem auto;padding:0 1rem;font:16px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif\"><h1 style=\"margin-bottom:0.5rem\">Finalizing plan upgrade</h1><p id=\"purchase-activation-continue-status\" style=\"margin-bottom:1rem\">Pulse is securely finalizing the completed checkout.</p><form id=\"purchase-activation-continue-form\" method=\"POST\" action=\"%s\"><input type=\"hidden\" name=\"%s\" value=\"%s\"><input type=\"hidden\" name=\"%s\" value=\"%s\">%s%s<button type=\"submit\">Continue to Plans</button></form></main><script>(function(){var form=document.getElementById('purchase-activation-continue-form');var statusEl=document.getElementById('purchase-activation-continue-status');if(statusEl){statusEl.textContent='Pulse is finishing the upgrade and returning you to Plans.';}if(form&&typeof form.submit==='function'){setTimeout(function(){form.submit();},50);}}());</script></body></html>",
|
|
licensePurchaseActivationPath,
|
|
licensePurchaseSessionIDField,
|
|
escapedSessionID,
|
|
licensePurchaseReturnTokenField,
|
|
escapedReturnToken,
|
|
featureInput,
|
|
portalHandoffInput,
|
|
)
|
|
}
|
|
|
|
func (h *LicenseHandlers) verifiedPurchaseReturnClaims(
|
|
r *http.Request,
|
|
returnToken string,
|
|
) (*purchaseReturnClaimsModel, error) {
|
|
expectedHost := purchaseReturnExpectedHost(r, h.cfg)
|
|
if expectedHost == "" {
|
|
return nil, fmt.Errorf("purchase return expected host is unavailable")
|
|
}
|
|
signingKey, err := h.purchaseReturnSigningKey()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return verifyPurchaseReturnTokenFromLicensing(returnToken, signingKey, expectedHost, time.Now().UTC())
|
|
}
|
|
|
|
func normalizeHostForTrial(raw string) string {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return ""
|
|
}
|
|
if strings.Contains(raw, "://") {
|
|
if parsed, err := url.Parse(raw); err == nil {
|
|
raw = parsed.Host
|
|
}
|
|
}
|
|
if host, _, err := net.SplitHostPort(raw); err == nil && host != "" {
|
|
raw = host
|
|
}
|
|
raw = strings.Trim(raw, "[]")
|
|
return strings.ToLower(strings.TrimSpace(raw))
|
|
}
|
|
|
|
// getTenantComponents resolves the license service and persistence for the current tenant.
|
|
// It initializes them if they haven't been loaded yet.
|
|
func (h *LicenseHandlers) getTenantComponents(ctx context.Context) (*licenseService, *licensePersistence, error) {
|
|
orgID := GetOrgID(ctx)
|
|
|
|
// Check if service already exists
|
|
if v, ok := h.services.Load(orgID); ok {
|
|
svc := v.(*licenseService)
|
|
svc.SetClientVersion(h.runtimeVersion)
|
|
svc.SetRuntimeIdentity(h.currentRuntimeIdentity())
|
|
if err := h.ensureEvaluatorForOrg(orgID, svc); err != nil {
|
|
log.Warn().Str("org_id", orgID).Err(err).Msg("Failed to refresh license evaluator for org")
|
|
}
|
|
h.ensureHostedEntitlementRefreshForOrg(orgID, svc)
|
|
// We need persistence too, reconstruct it or cache it?
|
|
// Reconstructing persistence is cheap (just a struct with path).
|
|
// But let's recreate it to be safe and stateless here.
|
|
// Actually, we need the EXACT persistence object if it holds state, but license.Persistence seems stateless (file I/O).
|
|
p, err := h.getPersistenceForOrg(orgID)
|
|
h.syncReleaseDemoFixtureRuntime(orgID, svc)
|
|
return svc, p, err
|
|
}
|
|
|
|
// Initialize for this tenant
|
|
persistence, err := h.getPersistenceForOrg(orgID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
service := newLicenseService()
|
|
service.SetClientVersion(h.runtimeVersion)
|
|
service.SetRuntimeIdentity(h.currentRuntimeIdentity())
|
|
|
|
// Wire license server client and persistence so activation / refresh can use them.
|
|
lsClient := newLicenseServerClientFromLicensing("")
|
|
service.SetLicenseServerClient(lsClient)
|
|
if persistence != nil {
|
|
service.SetPersistence(persistence)
|
|
}
|
|
|
|
if err := h.ensureEvaluatorForOrg(orgID, service); err != nil {
|
|
log.Warn().Str("org_id", orgID).Err(err).Msg("Failed to initialize license evaluator for org")
|
|
}
|
|
|
|
// Restore activation state from v6 grant persistence when present.
|
|
if persistence != nil {
|
|
activationState, loadErr := persistence.LoadActivationState()
|
|
if loadErr != nil {
|
|
log.Warn().Str("org_id", orgID).Err(loadErr).Msg("Failed to load activation state")
|
|
} else if activationState != nil {
|
|
if err := service.RestoreActivation(activationState); err != nil {
|
|
log.Warn().Str("org_id", orgID).Err(err).Msg("Failed to restore activation")
|
|
} else {
|
|
if clearErr := h.setCommercialMigrationState(orgID, nil); clearErr != nil {
|
|
log.Warn().Str("org_id", orgID).Err(clearErr).Msg("Failed to clear commercial migration state after activation restore")
|
|
}
|
|
// Start the background refresh loop for the restored grant.
|
|
service.StartGrantRefresh(context.Background())
|
|
service.StartInstallationStatusPoll(context.Background())
|
|
log.Info().Str("org_id", orgID).Str("license_id", activationState.LicenseID).Msg("Restored license activation")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Strict v6 automatically exchanges persisted legacy JWT licenses into the
|
|
// activation/grant model on startup when no activation state exists yet.
|
|
if persistence != nil && !isLicenseValidationDevModeFromLicensing() && !service.IsActivated() {
|
|
legacyJWT, loadErr := persistence.Load()
|
|
if loadErr != nil {
|
|
if !os.IsNotExist(loadErr) {
|
|
log.Warn().Str("org_id", orgID).Err(loadErr).Msg("Failed to load persisted legacy license")
|
|
// A license.enc that exists but cannot be read means a paid v5
|
|
// install is about to present as Community — surface it as a
|
|
// migration notice instead of degrading silently.
|
|
if persistErr := h.setCommercialMigrationState(orgID, classifyPersistedLicenseLoadErrorFromLicensing(loadErr)); persistErr != nil {
|
|
log.Warn().Str("org_id", orgID).Err(persistErr).Msg("Failed to persist commercial migration state for unreadable legacy license")
|
|
}
|
|
}
|
|
} else if strings.TrimSpace(legacyJWT) != "" {
|
|
if _, err := service.Activate(legacyJWT); err != nil {
|
|
migrationStatus := classifyLegacyExchangeErrorFromLicensing(err)
|
|
if persistErr := h.setCommercialMigrationState(orgID, migrationStatus); persistErr != nil {
|
|
log.Warn().Str("org_id", orgID).Err(persistErr).Msg("Failed to persist commercial migration state")
|
|
}
|
|
log.Warn().Str("org_id", orgID).Err(err).Msg("Failed to auto-exchange persisted legacy license")
|
|
if migrationStatus != nil && migrationStatus.State == commercialMigrationStatePendingValue {
|
|
h.scheduleLegacyExchangeRetry(orgID, legacyJWT)
|
|
}
|
|
} else if service.IsActivated() {
|
|
if clearErr := h.setCommercialMigrationState(orgID, nil); clearErr != nil {
|
|
log.Warn().Str("org_id", orgID).Err(clearErr).Msg("Failed to clear commercial migration state after successful auto-exchange")
|
|
}
|
|
service.StartGrantRefresh(context.Background())
|
|
service.StartInstallationStatusPoll(context.Background())
|
|
if current := service.Current(); current != nil {
|
|
log.Info().
|
|
Str("org_id", orgID).
|
|
Str("license_id", current.Claims.LicenseID).
|
|
Msg("Auto-exchanged persisted legacy license while preserving downgrade fallback")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Use LoadOrStore to avoid racing with concurrent first requests for the same org.
|
|
// If another goroutine stored first, use its service and let ours be GC'd.
|
|
actual, loaded := h.services.LoadOrStore(orgID, service)
|
|
if loaded {
|
|
service.StopGrantRefresh() // stop our orphaned refresh loop if started
|
|
service.StopInstallationStatusPoll() // stop our orphaned status poller if started
|
|
svc := actual.(*licenseService)
|
|
h.ensureHostedEntitlementRefreshForOrg(orgID, svc)
|
|
p, pErr := h.getPersistenceForOrg(orgID)
|
|
h.syncReleaseDemoFixtureRuntime(orgID, svc)
|
|
return svc, p, pErr
|
|
}
|
|
|
|
h.ensureHostedEntitlementRefreshForOrg(orgID, service)
|
|
h.syncReleaseDemoFixtureRuntime(orgID, service)
|
|
|
|
return service, persistence, nil
|
|
}
|
|
|
|
func (h *LicenseHandlers) demoFixturesAuthorized(service *licenseService) bool {
|
|
return h != nil &&
|
|
h.cfg != nil &&
|
|
h.cfg.DemoMode &&
|
|
service != nil &&
|
|
service.HasFeature(featureDemoFixturesValue)
|
|
}
|
|
|
|
func (h *LicenseHandlers) syncReleaseDemoFixtureRuntime(orgID string, service *licenseService) {
|
|
if !shouldEnforceReleaseDemoFixtureRuntime() {
|
|
return
|
|
}
|
|
|
|
if strings.TrimSpace(orgID) == "" {
|
|
orgID = "default"
|
|
}
|
|
if orgID != "default" {
|
|
return
|
|
}
|
|
|
|
authorized := h.demoFixturesAuthorized(service)
|
|
mock.SetReleaseFixturesAuthorized(authorized)
|
|
|
|
if !authorized {
|
|
if h.monitor != nil {
|
|
if err := h.monitor.SetMockMode(false); err != nil {
|
|
log.Warn().Err(err).Str("org_id", orgID).Msg("Failed to disable unauthorized demo fixtures")
|
|
}
|
|
} else if err := mock.SetEnabled(false); err != nil {
|
|
log.Warn().Err(err).Str("org_id", orgID).Msg("Failed to disable unauthorized demo fixtures")
|
|
}
|
|
return
|
|
}
|
|
|
|
if !wantsMockFixturesFromEnv() {
|
|
return
|
|
}
|
|
|
|
if h.monitor != nil {
|
|
if err := h.monitor.SetMockMode(true); err != nil {
|
|
log.Warn().Err(err).Str("org_id", orgID).Msg("Failed to enable entitled demo fixtures")
|
|
}
|
|
return
|
|
}
|
|
|
|
if err := mock.SetEnabled(true); err != nil {
|
|
log.Warn().Err(err).Str("org_id", orgID).Msg("Failed to enable entitled demo fixtures")
|
|
}
|
|
}
|
|
|
|
func (h *LicenseHandlers) ensureEvaluatorForOrg(orgID string, service *licenseService) error {
|
|
if h == nil || service == nil || h.mtPersistence == nil {
|
|
return nil
|
|
}
|
|
// Never override token-backed evaluator when a JWT license is present.
|
|
if service.Current() != nil {
|
|
return nil
|
|
}
|
|
|
|
billingStore := config.NewFileBillingStore(h.mtPersistence.BaseDataDir())
|
|
|
|
// Hosted tenant runtimes still carry instance-scoped billing state in the
|
|
// root billing.json. If a tenant-scoped org has no org-local billing state,
|
|
// inherit the default-org lease so hosted auth handoff can preserve tenant
|
|
// org context without dropping entitlements on first runtime entry.
|
|
if h.hostedMode && orgID != "default" && orgID != "" {
|
|
evaluatorOrgID := orgID
|
|
state, err := billingStore.GetBillingState(orgID)
|
|
if err != nil {
|
|
return fmt.Errorf("load hosted billing state for org %q: %w", orgID, err)
|
|
}
|
|
if state == nil || state.SubscriptionState == "" {
|
|
defaultState, defaultErr := billingStore.GetBillingState("default")
|
|
if defaultErr != nil {
|
|
return fmt.Errorf("load hosted default billing state fallback: %w", defaultErr)
|
|
}
|
|
if defaultState == nil || defaultState.SubscriptionState == "" {
|
|
service.SetEvaluator(nil)
|
|
return nil
|
|
}
|
|
evaluatorOrgID = "default"
|
|
}
|
|
evaluator := newLicenseEvaluatorForBillingStoreFromLicensing(billingStore, evaluatorOrgID, time.Hour, entitlementExpectedInstanceHost(h.cfg))
|
|
service.SetEvaluator(evaluator)
|
|
return nil
|
|
}
|
|
|
|
// Self-hosted mode:
|
|
// - Default org uses its own billing state.
|
|
// - Non-default orgs inherit default-org billing state when they do not yet have
|
|
// org-local billing state. This keeps instance-wide licenses/trials consistent
|
|
// across tenant contexts in self-hosted deployments.
|
|
evaluatorOrgID := orgID
|
|
|
|
state, err := billingStore.GetBillingState(orgID)
|
|
if err != nil {
|
|
return fmt.Errorf("load billing state for org %q: %w", orgID, err)
|
|
}
|
|
if (state == nil || state.SubscriptionState == "") && orgID != "" && orgID != "default" {
|
|
defaultState, defaultErr := billingStore.GetBillingState("default")
|
|
if defaultErr != nil {
|
|
return fmt.Errorf("load default billing state fallback: %w", defaultErr)
|
|
}
|
|
if defaultState != nil && defaultState.SubscriptionState != "" {
|
|
state = defaultState
|
|
evaluatorOrgID = "default"
|
|
}
|
|
}
|
|
|
|
if state == nil || state.SubscriptionState == "" {
|
|
service.SetEvaluator(nil)
|
|
return nil
|
|
}
|
|
|
|
// Billing state exists: wire evaluator without caching so UI updates immediately after writes.
|
|
evaluator := newLicenseEvaluatorForBillingStoreFromLicensing(billingStore, evaluatorOrgID, 0, entitlementExpectedInstanceHost(h.cfg))
|
|
service.SetEvaluator(evaluator)
|
|
return nil
|
|
}
|
|
|
|
func (h *LicenseHandlers) getPersistenceForOrg(orgID string) (*licensePersistence, error) {
|
|
configPersistence, err := h.mtPersistence.GetPersistence(orgID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return newLicensePersistenceFromLicensing(configPersistence.GetConfigDir())
|
|
}
|
|
|
|
func (h *LicenseHandlers) setCommercialMigrationState(orgID string, status *commercialMigrationStatusModel) error {
|
|
if h == nil || h.mtPersistence == nil {
|
|
return nil
|
|
}
|
|
|
|
billingStore := config.NewFileBillingStore(h.mtPersistence.BaseDataDir())
|
|
existing, err := billingStore.GetBillingState(orgID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if existing == nil {
|
|
if status == nil {
|
|
return nil
|
|
}
|
|
existing = &billingState{
|
|
Capabilities: []string{},
|
|
Limits: map[string]int64{},
|
|
MetersEnabled: []string{},
|
|
PlanVersion: string(subscriptionStateExpiredValue),
|
|
SubscriptionState: subscriptionStateExpiredValue,
|
|
}
|
|
} else {
|
|
existing = normalizeBillingStateFromLicensing(existing)
|
|
if existing.SubscriptionState == "" {
|
|
existing.PlanVersion = string(subscriptionStateExpiredValue)
|
|
existing.SubscriptionState = subscriptionStateExpiredValue
|
|
}
|
|
}
|
|
|
|
now := time.Now
|
|
if h.commercialMigrationNow != nil {
|
|
now = h.commercialMigrationNow
|
|
}
|
|
existing.CommercialMigration = applyCommercialMigrationFailureTimingFromLicensing(
|
|
status,
|
|
existing.CommercialMigration,
|
|
now(),
|
|
)
|
|
return billingStore.SaveBillingState(orgID, existing)
|
|
}
|
|
|
|
// Service returns the license service for use by other handlers.
|
|
// NOTE: This now requires context to identify the tenant.
|
|
// Handlers using this will need to be updated.
|
|
func (h *LicenseHandlers) Service(ctx context.Context) *licenseService {
|
|
svc, _, _ := h.getTenantComponents(ctx)
|
|
return svc
|
|
}
|
|
|
|
// FeatureService resolves a request-scoped feature checker.
|
|
// This satisfies pkg/licensing.FeatureServiceResolver for reusable middleware.
|
|
func (h *LicenseHandlers) FeatureService(ctx context.Context) licenseFeatureChecker {
|
|
if h == nil {
|
|
return nil
|
|
}
|
|
return h.Service(ctx)
|
|
}
|
|
|
|
type hostedTrialLeaseRefreshRequest struct {
|
|
OrgID string `json:"org_id"`
|
|
InstanceHost string `json:"instance_host"`
|
|
EntitlementRefreshToken string `json:"entitlement_refresh_token"`
|
|
}
|
|
|
|
type hostedTrialLeaseRefreshResponse struct {
|
|
EntitlementJWT string `json:"entitlement_jwt"`
|
|
}
|
|
|
|
func hostedEntitlementRefreshURLFromConfig(cfg *config.Config) string {
|
|
signupBaseURL := ""
|
|
if cfg != nil {
|
|
signupBaseURL = strings.TrimSpace(cfg.ProTrialSignupURL)
|
|
}
|
|
signupURL := strings.TrimSpace(proTrialSignupURLFromLicensing(signupBaseURL))
|
|
if signupURL == "" {
|
|
return ""
|
|
}
|
|
parsed, err := parseHostedCommercialURL(signupURL)
|
|
if err != nil || parsed == nil {
|
|
return ""
|
|
}
|
|
parsed.Path = "/api/entitlements/refresh"
|
|
parsed.RawQuery = ""
|
|
return parsed.String()
|
|
}
|
|
|
|
func entitlementExpectedInstanceHost(cfg *config.Config) string {
|
|
if cfg == nil {
|
|
return ""
|
|
}
|
|
return normalizeHostForTrial(cfg.PublicURL)
|
|
}
|
|
|
|
// HandleLicenseStatus handles GET /api/license/status
|
|
// Returns the current license status.
|
|
func (h *LicenseHandlers) HandleLicenseStatus(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
service, _, err := h.getTenantComponents(r.Context())
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to get license components")
|
|
http.Error(w, "Tenant error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
status := service.Status()
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(status)
|
|
}
|
|
|
|
// LicenseFeaturesResponse provides a minimal, non-admin license view for feature gating.
|
|
type LicenseFeaturesResponse = licenseFeaturesResponse
|
|
|
|
// HandleLicenseFeatures handles GET /api/license/features
|
|
// Returns license state and feature availability for authenticated users.
|
|
func (h *LicenseHandlers) HandleLicenseFeatures(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
service, _, err := h.getTenantComponents(r.Context())
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to get license components")
|
|
http.Error(w, "Tenant error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
state, _ := service.GetLicenseState()
|
|
response := LicenseFeaturesResponse{
|
|
LicenseStatus: string(state),
|
|
Features: buildFeatureMapFromLicensing(service),
|
|
UpgradeURL: upgradeURLForFeatureFromLicensing(""),
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(response)
|
|
}
|
|
|
|
// ActivateLicenseRequest is the request body for activating a license.
|
|
type ActivateLicenseRequest = activateLicenseRequestModel
|
|
|
|
// ActivateLicenseResponse is the response for license activation.
|
|
type ActivateLicenseResponse = activateLicenseResponseModel
|
|
|
|
// userFriendlyActivationError maps internal activation errors to user-facing messages.
|
|
// The raw error should already be logged before calling this function.
|
|
func userFriendlyActivationError(err error) string {
|
|
switch {
|
|
case errors.Is(err, errMalformedLicenseSentinel):
|
|
return "The license key format is not valid. Please check for typos and try again."
|
|
case errors.Is(err, errInvalidLicenseSentinel):
|
|
return "The license key is not valid. Please check for typos and try again."
|
|
case errors.Is(err, errSignatureInvalidSentinel):
|
|
return "The license key could not be verified. Please ensure you are using the correct key."
|
|
case errors.Is(err, errExpiredLicenseSentinel):
|
|
return "This license has expired. Contact support for renewal options."
|
|
case errors.Is(err, errNoPublicKeySentinel):
|
|
return "This Pulse build cannot verify Pro licenses because it was built without the license validation key. Use the official Pulse release tarball or published Docker image, then activate the same license key again."
|
|
}
|
|
|
|
// License server errors from the activation key flow.
|
|
var serverErr *licenseServerErrorModel
|
|
if errors.As(err, &serverErr) {
|
|
if serverErr.Retryable {
|
|
return "The license server is temporarily unavailable. Please try again in a few minutes."
|
|
}
|
|
if serverErr.Message != "" {
|
|
return serverErr.Message
|
|
}
|
|
}
|
|
|
|
// Known non-sentinel patterns.
|
|
msg := err.Error()
|
|
if strings.Contains(msg, "supported v6 activation key") || strings.Contains(msg, "migratable v5 license") {
|
|
return "This key is not a valid Pulse v6 activation key or a supported Pulse v5 license for migration. Paste a v6 activation key, or a valid v5 Pro/Lifetime license and Pulse will exchange it automatically."
|
|
}
|
|
if strings.Contains(msg, "license server client not configured") {
|
|
return "License activation is temporarily unavailable. Please try again later or contact support."
|
|
}
|
|
|
|
return "License activation failed. Please try again or contact support if the problem persists."
|
|
}
|
|
|
|
// HandleActivateLicense handles POST /api/license/activate
|
|
// Validates and activates a license key.
|
|
func (h *LicenseHandlers) activateLicenseKey(ctx context.Context, licenseKey string) (ActivateLicenseResponse, error) {
|
|
service, persistence, err := h.getTenantComponents(ctx)
|
|
if err != nil {
|
|
return ActivateLicenseResponse{}, fmt.Errorf("resolve tenant licensing components: %w", err)
|
|
}
|
|
|
|
orgID := GetOrgID(ctx)
|
|
trimmedKey := strings.TrimSpace(licenseKey)
|
|
migratedLegacyKey := !strings.HasPrefix(trimmedKey, activationKeyPrefixValue)
|
|
|
|
lic, err := service.Activate(trimmedKey)
|
|
if err != nil {
|
|
return ActivateLicenseResponse{}, err
|
|
}
|
|
|
|
if service.IsActivated() {
|
|
h.stopHostedEntitlementRefreshLoop(orgID)
|
|
h.stopLegacyExchangeRetry(orgID)
|
|
if clearErr := h.setCommercialMigrationState(orgID, nil); clearErr != nil {
|
|
log.Warn().Err(clearErr).Str("org_id", orgID).Msg("Failed to clear commercial migration state after activation")
|
|
}
|
|
// Activation state is already persisted by ActivateWithKey, but start the refresh loop.
|
|
service.StartGrantRefresh(context.Background())
|
|
service.StartInstallationStatusPoll(context.Background())
|
|
if persistence != nil {
|
|
if strings.HasPrefix(trimmedKey, activationKeyPrefixValue) {
|
|
_ = persistence.Delete()
|
|
} else if err := persistence.Save(trimmedKey); err != nil {
|
|
log.Warn().Err(err).Msg("Failed to persist migrated legacy license for downgrade fallback")
|
|
}
|
|
}
|
|
log.Info().
|
|
Str("tier", string(lic.Claims.Tier)).
|
|
Msg("Pulse license activated via activation key")
|
|
} else {
|
|
// Strict v6: legacy JWT activation is only possible in explicit dev mode.
|
|
log.Info().
|
|
Str("email", lic.Claims.Email).
|
|
Str("tier", string(lic.Claims.Tier)).
|
|
Bool("lifetime", lic.IsLifetime()).
|
|
Msg("Pulse license activated in development JWT mode")
|
|
}
|
|
|
|
h.syncReleaseDemoFixtureRuntime(orgID, service)
|
|
|
|
successMessage := "License activated successfully"
|
|
if migratedLegacyKey && service.IsActivated() {
|
|
successMessage = "Pulse v5 license migrated and activated successfully"
|
|
}
|
|
|
|
return ActivateLicenseResponse{
|
|
Success: true,
|
|
Message: successMessage,
|
|
Status: service.Status(),
|
|
}, nil
|
|
}
|
|
|
|
func (h *LicenseHandlers) HandleActivateLicense(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
var req ActivateLicenseRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
json.NewEncoder(w).Encode(ActivateLicenseResponse{
|
|
Success: false,
|
|
Message: "Invalid request body",
|
|
})
|
|
return
|
|
}
|
|
|
|
if strings.TrimSpace(req.LicenseKey) == "" {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
json.NewEncoder(w).Encode(ActivateLicenseResponse{
|
|
Success: false,
|
|
Message: "License key is required",
|
|
})
|
|
return
|
|
}
|
|
|
|
response, err := h.activateLicenseKey(r.Context(), req.LicenseKey)
|
|
if err != nil {
|
|
log.Warn().Err(err).Msg("Failed to activate license")
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
json.NewEncoder(w).Encode(ActivateLicenseResponse{
|
|
Success: false,
|
|
Message: userFriendlyActivationError(err),
|
|
})
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(response)
|
|
}
|
|
|
|
// HandleCheckoutStart handles GET /auth/license-purchase-start.
|
|
// It mints a short-lived signed return token and redirects the operator into
|
|
// Pulse Account so checkout can safely return to the local instance.
|
|
func (h *LicenseHandlers) HandleCheckoutStart(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
feature := normalizeSelfHostedPurchaseFeature(
|
|
r.URL.Query().Get(pulseAccountPortalFeatureQueryParam),
|
|
)
|
|
writeUnavailable := func(statusCode int, message string) {
|
|
writeLicensePurchaseStartFailurePage(w, statusCode, feature, message)
|
|
}
|
|
|
|
expectedHost := purchaseReturnExpectedHost(r, h.cfg)
|
|
returnURL := licensePurchaseCallbackURLForRequest(r, h.cfg)
|
|
if expectedHost == "" || returnURL == "" {
|
|
writeUnavailable(
|
|
http.StatusServiceUnavailable,
|
|
"Pulse could not open Pulse Account from this instance right now. Retry from this instance in a moment, or use recovery below if you already have a key.",
|
|
)
|
|
return
|
|
}
|
|
|
|
signingKey, err := h.purchaseReturnSigningKey()
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Purchase return signing key unavailable")
|
|
writeUnavailable(
|
|
http.StatusServiceUnavailable,
|
|
"Pulse could not prepare the secure Pulse Account handoff for this instance. Retry in a moment, or use recovery below if you already have a key.",
|
|
)
|
|
return
|
|
}
|
|
|
|
orgID := strings.TrimSpace(GetOrgID(r.Context()))
|
|
if orgID == "" {
|
|
orgID = "default"
|
|
}
|
|
returnToken, err := signPurchaseReturnTokenFromLicensing(signingKey, purchaseReturnClaimsModel{
|
|
OrgID: orgID,
|
|
Feature: feature,
|
|
InstanceHost: expectedHost,
|
|
ReturnURL: returnURL,
|
|
})
|
|
if err != nil {
|
|
log.Error().Err(err).Str("org_id", orgID).Str("feature", feature).Msg("Failed to sign purchase return token")
|
|
writeUnavailable(
|
|
http.StatusServiceUnavailable,
|
|
"Pulse could not prepare the secure Pulse Account handoff for this instance. Retry in a moment, or use recovery below if you already have a key.",
|
|
)
|
|
return
|
|
}
|
|
|
|
activationURLTemplate, err := licensePurchaseActivationTemplateURL(returnURL, returnToken)
|
|
if err != nil {
|
|
log.Error().Err(err).Str("feature", feature).Msg("Failed to build Pulse Account activation template")
|
|
writeUnavailable(
|
|
http.StatusServiceUnavailable,
|
|
"Pulse could not prepare the secure Pulse Account handoff for this instance. Retry in a moment, or use recovery below if you already have a key.",
|
|
)
|
|
return
|
|
}
|
|
|
|
cancelURL, err := publicAbsoluteURLForPath(
|
|
r,
|
|
h.cfg,
|
|
licensePurchaseActivationRedirectPath(feature, selfHostedBillingPurchaseCancelled),
|
|
)
|
|
if err != nil {
|
|
log.Error().Err(err).Str("feature", feature).Msg("Failed to build Pulse Account cancellation return url")
|
|
writeUnavailable(
|
|
http.StatusServiceUnavailable,
|
|
"Pulse could not prepare the secure Pulse Account handoff for this instance. Retry in a moment, or use recovery below if you already have a key.",
|
|
)
|
|
return
|
|
}
|
|
|
|
lsClient := newLicenseServerClientFromLicensing("")
|
|
if lsClient == nil {
|
|
writeUnavailable(
|
|
http.StatusServiceUnavailable,
|
|
"Pulse could not contact the commercial service needed to open Pulse Account. Retry in a moment, or use recovery below if you already have a key.",
|
|
)
|
|
return
|
|
}
|
|
|
|
purchaseReturnJTI := ""
|
|
if claims, verifyErr := h.verifiedPurchaseReturnClaims(r, returnToken); verifyErr == nil && claims != nil {
|
|
purchaseReturnJTI = strings.TrimSpace(claims.ID)
|
|
}
|
|
if purchaseReturnJTI == "" {
|
|
log.Error().Str("feature", feature).Msg("Pulse Account purchase return token omitted jti")
|
|
writeUnavailable(
|
|
http.StatusServiceUnavailable,
|
|
"Pulse could not prepare the secure Pulse Account handoff for this instance. Retry in a moment, or use recovery below if you already have a key.",
|
|
)
|
|
return
|
|
}
|
|
|
|
portalHandoff, err := lsClient.CreateCheckoutPortalHandoff(r.Context(), checkoutPortalHandoffRequestModel{
|
|
Feature: feature,
|
|
SuccessURL: activationURLTemplate,
|
|
CancelURL: cancelURL,
|
|
PurchaseReturnJTI: purchaseReturnJTI,
|
|
})
|
|
if err != nil {
|
|
log.Error().Err(err).Str("feature", feature).Msg("Failed to create Pulse Account checkout portal handoff")
|
|
writeUnavailable(
|
|
http.StatusServiceUnavailable,
|
|
"Pulse could not open Pulse Account right now. Retry from this instance in a moment, or use recovery below if you already have a key.",
|
|
)
|
|
return
|
|
}
|
|
portalHandoffID := strings.TrimSpace(portalHandoff.PortalHandoffID)
|
|
if portalHandoffID == "" {
|
|
log.Error().Str("feature", feature).Msg("Pulse Account checkout portal handoff response omitted id")
|
|
writeUnavailable(
|
|
http.StatusServiceUnavailable,
|
|
"Pulse could not open Pulse Account right now. Retry from this instance in a moment, or use recovery below if you already have a key.",
|
|
)
|
|
return
|
|
}
|
|
|
|
destination, err := pulseAccountUpgradeURLForRequest(portalHandoffID, r.URL.Query())
|
|
if err != nil {
|
|
log.Error().Err(err).Str("feature", feature).Msg("Failed to build Pulse Account upgrade destination")
|
|
writeUnavailable(
|
|
http.StatusServiceUnavailable,
|
|
"Pulse could not open Pulse Account right now. Retry from this instance in a moment, or use recovery below if you already have a key.",
|
|
)
|
|
return
|
|
}
|
|
|
|
http.Redirect(w, r, destination, http.StatusSeeOther)
|
|
}
|
|
|
|
// HandleCheckoutActivation handles the local checkout return at
|
|
// /auth/license-purchase-activate. GET renders the auto-submitting bridge for
|
|
// a completed Stripe redirect, while POST redeems the completed session into a
|
|
// local activation and returns the operator to the canonical billing route.
|
|
func (h *LicenseHandlers) HandleCheckoutActivation(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == http.MethodGet {
|
|
sessionID := strings.TrimSpace(r.URL.Query().Get(licensePurchaseSessionIDField))
|
|
portalHandoffID := strings.TrimSpace(r.URL.Query().Get(pulseAccountPortalHandoffIDField))
|
|
feature := normalizeSelfHostedPurchaseFeature(r.URL.Query().Get("feature"))
|
|
returnToken := strings.TrimSpace(r.URL.Query().Get(licensePurchaseReturnTokenField))
|
|
if returnToken == "" || sessionID == "" {
|
|
writeLicensePurchaseActivationFailurePage(w, http.StatusBadRequest, feature, selfHostedBillingPurchaseExpired, "Purchase activation link expired or is missing required state. Reopen the upgrade flow from Plans in Pulse.")
|
|
return
|
|
}
|
|
claims, err := h.verifiedPurchaseReturnClaims(r, returnToken)
|
|
if err != nil {
|
|
log.Warn().Err(err).Msg("Purchase return token verification failed during GET bridge")
|
|
writeLicensePurchaseActivationFailurePage(w, http.StatusBadRequest, feature, selfHostedBillingPurchaseExpired, "Purchase activation link expired or is invalid. Reopen the upgrade flow from Plans in Pulse.")
|
|
return
|
|
}
|
|
if claims != nil && strings.TrimSpace(claims.Feature) != "" {
|
|
feature = normalizeSelfHostedPurchaseFeature(claims.Feature)
|
|
}
|
|
writeLicensePurchaseActivationContinuePage(w, sessionID, portalHandoffID, feature, returnToken)
|
|
return
|
|
}
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
writeLicensePurchaseActivationFailurePage(w, http.StatusBadRequest, "", selfHostedBillingPurchaseFailed, "Purchase activation request was invalid.")
|
|
return
|
|
}
|
|
|
|
sessionID := strings.TrimSpace(r.FormValue(licensePurchaseSessionIDField))
|
|
portalHandoffID := strings.TrimSpace(r.FormValue(pulseAccountPortalHandoffIDField))
|
|
feature := normalizeSelfHostedPurchaseFeature(r.FormValue("feature"))
|
|
returnToken := strings.TrimSpace(r.FormValue(licensePurchaseReturnTokenField))
|
|
if returnToken == "" {
|
|
writeLicensePurchaseActivationFailurePage(w, http.StatusBadRequest, feature, selfHostedBillingPurchaseExpired, "Purchase activation link expired or is missing required state. Reopen the upgrade flow from Plans in Pulse.")
|
|
return
|
|
}
|
|
|
|
claims, err := h.verifiedPurchaseReturnClaims(r, returnToken)
|
|
if err != nil {
|
|
log.Warn().Err(err).Msg("Purchase return token verification failed")
|
|
writeLicensePurchaseActivationFailurePage(w, http.StatusBadRequest, feature, selfHostedBillingPurchaseExpired, "Purchase activation link expired or is invalid. Reopen the upgrade flow from Plans in Pulse.")
|
|
return
|
|
}
|
|
if claims != nil && strings.TrimSpace(claims.Feature) != "" {
|
|
normalizedClaimsFeature := normalizeSelfHostedPurchaseFeature(claims.Feature)
|
|
if feature != "" && feature != normalizedClaimsFeature {
|
|
writeLicensePurchaseActivationFailurePage(w, http.StatusBadRequest, normalizedClaimsFeature, selfHostedBillingPurchaseExpired, "Purchase activation state did not match the completed upgrade flow.")
|
|
return
|
|
}
|
|
feature = normalizedClaimsFeature
|
|
}
|
|
if sessionID == "" {
|
|
writeLicensePurchaseActivationFailurePage(w, http.StatusBadRequest, feature, selfHostedBillingPurchaseFailed, "Purchase activation requires a completed checkout session.")
|
|
return
|
|
}
|
|
|
|
lsClient := newLicenseServerClientFromLicensing("")
|
|
if lsClient == nil {
|
|
writeLicensePurchaseActivationFailurePage(w, http.StatusServiceUnavailable, feature, selfHostedBillingPurchaseFailed, "Pulse could not contact the commercial activation service.")
|
|
return
|
|
}
|
|
|
|
ctx := r.Context()
|
|
if claims != nil && strings.TrimSpace(claims.OrgID) != "" {
|
|
ctx = context.WithValue(ctx, OrgIDContextKey, strings.TrimSpace(claims.OrgID))
|
|
}
|
|
|
|
checkoutResult, err := lsClient.GetCheckoutSessionResult(ctx, sessionID)
|
|
if err != nil {
|
|
log.Warn().Err(err).Str("checkout_session_id", sessionID).Msg("Failed to resolve checkout session during purchase activation")
|
|
writeLicensePurchaseActivationFailurePage(w, http.StatusBadGateway, feature, selfHostedBillingPurchaseFailed, "Pulse could not confirm the completed checkout yet. Please try again in a moment.")
|
|
return
|
|
}
|
|
|
|
if checkoutResult == nil || strings.TrimSpace(checkoutResult.Status) != "fulfilled" {
|
|
message := "Checkout has not completed yet."
|
|
if checkoutResult != nil && strings.TrimSpace(checkoutResult.Message) != "" {
|
|
message = strings.TrimSpace(checkoutResult.Message)
|
|
}
|
|
writeLicensePurchaseActivationFailurePage(w, http.StatusConflict, feature, selfHostedBillingPurchaseFailed, message)
|
|
return
|
|
}
|
|
expectedPurchaseReturnJTI := ""
|
|
if claims != nil {
|
|
expectedPurchaseReturnJTI = strings.TrimSpace(claims.ID)
|
|
}
|
|
resolvedPurchaseReturnJTI := ""
|
|
if checkoutResult != nil {
|
|
resolvedPurchaseReturnJTI = strings.TrimSpace(checkoutResult.PurchaseReturnJTI)
|
|
}
|
|
resolvedPortalHandoffID := ""
|
|
if checkoutResult != nil {
|
|
resolvedPortalHandoffID = strings.TrimSpace(checkoutResult.PortalHandoffID)
|
|
}
|
|
if portalHandoffID != "" {
|
|
if resolvedPortalHandoffID == "" || resolvedPortalHandoffID != portalHandoffID {
|
|
log.Warn().
|
|
Str("checkout_session_id", sessionID).
|
|
Str("expected_portal_handoff_id", portalHandoffID).
|
|
Str("resolved_portal_handoff_id", resolvedPortalHandoffID).
|
|
Msg("Rejected checkout activation due to portal handoff binding mismatch")
|
|
writeLicensePurchaseActivationFailurePage(w, http.StatusConflict, feature, selfHostedBillingPurchaseExpired, "Purchase activation state did not match the completed upgrade flow. Reopen the upgrade flow from Plans in Pulse.")
|
|
return
|
|
}
|
|
} else {
|
|
portalHandoffID = resolvedPortalHandoffID
|
|
}
|
|
if portalHandoffID == "" {
|
|
log.Warn().
|
|
Str("checkout_session_id", sessionID).
|
|
Msg("Rejected checkout activation without canonical portal handoff binding")
|
|
writeLicensePurchaseActivationFailurePage(w, http.StatusConflict, feature, selfHostedBillingPurchaseExpired, "Purchase activation state did not match the completed upgrade flow. Reopen the upgrade flow from Plans in Pulse.")
|
|
return
|
|
}
|
|
if expectedPurchaseReturnJTI == "" || resolvedPurchaseReturnJTI == "" || expectedPurchaseReturnJTI != resolvedPurchaseReturnJTI {
|
|
log.Warn().
|
|
Str("checkout_session_id", sessionID).
|
|
Str("expected_purchase_return_jti", expectedPurchaseReturnJTI).
|
|
Str("resolved_purchase_return_jti", resolvedPurchaseReturnJTI).
|
|
Msg("Rejected checkout activation due to purchase return binding mismatch")
|
|
writeLicensePurchaseActivationFailurePage(w, http.StatusConflict, feature, selfHostedBillingPurchaseExpired, "Purchase activation state did not match the completed upgrade flow. Reopen the upgrade flow from Plans in Pulse.")
|
|
return
|
|
}
|
|
|
|
activationKey := strings.TrimSpace(checkoutResult.ActivationKey)
|
|
if activationKey == "" {
|
|
writeLicensePurchaseActivationFailurePage(w, http.StatusBadGateway, feature, selfHostedBillingPurchaseFailed, "The completed checkout did not return an activation key.")
|
|
return
|
|
}
|
|
|
|
redemptionStore := h.purchaseReturnRedemptionStore()
|
|
if redemptionStore == nil {
|
|
writeLicensePurchaseActivationFailurePage(w, http.StatusServiceUnavailable, feature, selfHostedBillingPurchaseFailed, "Pulse could not verify the purchase activation state for this checkout.")
|
|
return
|
|
}
|
|
expiresAt := time.Now().UTC().Add(2 * time.Hour)
|
|
if claims != nil && claims.ExpiresAt != nil {
|
|
expiresAt = claims.ExpiresAt.Time
|
|
}
|
|
redemptionDecision, _, err := redemptionStore.begin(purchaseReturnRedemptionAttempt{
|
|
PortalHandoffID: portalHandoffID,
|
|
PurchaseReturnJTI: expectedPurchaseReturnJTI,
|
|
CheckoutSessionID: sessionID,
|
|
LicenseID: strings.TrimSpace(checkoutResult.LicenseID),
|
|
ActivationKeyPrefix: strings.TrimSpace(checkoutResult.ActivationKeyPrefix),
|
|
ExpiresAt: expiresAt,
|
|
})
|
|
if err != nil {
|
|
log.Error().Err(err).Str("portal_handoff_id", portalHandoffID).Msg("Purchase activation redemption-store failure")
|
|
writeLicensePurchaseActivationFailurePage(w, http.StatusServiceUnavailable, feature, selfHostedBillingPurchaseFailed, "Pulse could not verify the purchase activation state for this checkout.")
|
|
return
|
|
}
|
|
switch redemptionDecision {
|
|
case purchaseReturnRedemptionDecisionAlreadyActivated:
|
|
writeLicensePurchaseActivationFailurePage(w, http.StatusConflict, feature, selfHostedBillingPurchaseActivated, "This completed purchase was already returned to Plans. Reopen Plans if you need to confirm the current entitlement.")
|
|
return
|
|
case purchaseReturnRedemptionDecisionInProgress:
|
|
writeLicensePurchaseActivationFailurePage(w, http.StatusConflict, feature, selfHostedBillingPurchaseFailed, "Pulse is already finalizing this completed purchase. Reopen Plans in a moment if this tab does not close automatically.")
|
|
return
|
|
case purchaseReturnRedemptionDecisionConflict:
|
|
log.Warn().
|
|
Str("checkout_session_id", sessionID).
|
|
Str("portal_handoff_id", portalHandoffID).
|
|
Msg("Rejected checkout activation due to local redemption binding conflict")
|
|
writeLicensePurchaseActivationFailurePage(w, http.StatusConflict, feature, selfHostedBillingPurchaseExpired, "Purchase activation state did not match the completed upgrade flow. Reopen the upgrade flow from Plans in Pulse.")
|
|
return
|
|
}
|
|
recordFailure := func(reason string, activationErr error) {
|
|
message := ""
|
|
if activationErr != nil {
|
|
message = activationErr.Error()
|
|
}
|
|
if markErr := redemptionStore.markFailed(portalHandoffID, expectedPurchaseReturnJTI, reason, message); markErr != nil {
|
|
log.Warn().
|
|
Err(markErr).
|
|
Str("portal_handoff_id", portalHandoffID).
|
|
Str("purchase_return_jti", expectedPurchaseReturnJTI).
|
|
Str("reason", reason).
|
|
Msg("Failed to update purchase activation redemption record")
|
|
return
|
|
}
|
|
if activationErr != nil {
|
|
log.Warn().
|
|
Err(activationErr).
|
|
Str("portal_handoff_id", portalHandoffID).
|
|
Str("purchase_return_jti", expectedPurchaseReturnJTI).
|
|
Str("reason", reason).
|
|
Msg("Marked purchase activation redemption as failed")
|
|
}
|
|
}
|
|
|
|
if _, err := h.activateLicenseKey(ctx, activationKey); err != nil {
|
|
recordFailure("license_activation_failed", err)
|
|
log.Warn().Err(err).Str("checkout_session_id", sessionID).Msg("Failed to activate completed checkout locally")
|
|
writeLicensePurchaseActivationFailurePage(w, http.StatusBadRequest, feature, selfHostedBillingPurchaseFailed, userFriendlyActivationError(err))
|
|
return
|
|
}
|
|
if err := redemptionStore.markActivated(portalHandoffID, expectedPurchaseReturnJTI, strings.TrimSpace(checkoutResult.LicenseID), strings.TrimSpace(checkoutResult.ActivationKeyPrefix)); err != nil {
|
|
log.Error().Err(err).Str("portal_handoff_id", portalHandoffID).Str("checkout_session_id", sessionID).Msg("Failed to finalize purchase activation redemption record")
|
|
writeLicensePurchaseActivationFailurePage(w, http.StatusServiceUnavailable, feature, selfHostedBillingPurchaseFailed, "Pulse activated the license but could not finalize the local purchase record. Reopen billing to confirm the current entitlement.")
|
|
return
|
|
}
|
|
|
|
writeLicensePurchaseActivationSuccessPage(w, feature)
|
|
}
|
|
|
|
// HandleClearLicense handles POST /api/license/clear
|
|
// Removes the current license.
|
|
func (h *LicenseHandlers) HandleClearLicense(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
// Clear from service
|
|
service, persistence, err := h.getTenantComponents(r.Context())
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to get license components")
|
|
http.Error(w, "Tenant error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
service.Clear()
|
|
h.syncReleaseDemoFixtureRuntime(GetOrgID(r.Context()), service)
|
|
|
|
orgID := GetOrgID(r.Context())
|
|
if orgID == "" {
|
|
orgID = "default"
|
|
}
|
|
|
|
// Clear from persistence (both legacy JWT and activation state).
|
|
if persistence != nil {
|
|
if err := persistence.Delete(); err != nil {
|
|
log.Warn().Err(err).Msg("Failed to delete persisted license")
|
|
}
|
|
if err := persistence.ClearActivationState(); err != nil {
|
|
log.Warn().Err(err).Msg("Failed to delete persisted activation state")
|
|
}
|
|
}
|
|
|
|
// Clear any locally cached billing-backed entitlement grant as well.
|
|
// Preserve trial_started_at and free-tier bookkeeping so the effective trial
|
|
// ends immediately but trial reuse remains blocked.
|
|
if h != nil && h.mtPersistence != nil {
|
|
h.stopHostedEntitlementRefreshLoop(orgID)
|
|
billingStore := config.NewFileBillingStore(h.mtPersistence.BaseDataDir())
|
|
existing, err := billingStore.GetBillingState(orgID)
|
|
if err != nil {
|
|
log.Warn().Err(err).Str("org_id", orgID).Msg("Failed to load billing state while clearing license")
|
|
} else if existing != nil {
|
|
existing.Capabilities = []string{}
|
|
existing.Limits = map[string]int64{}
|
|
existing.MetersEnabled = []string{}
|
|
existing.EntitlementJWT = ""
|
|
existing.EntitlementRefreshToken = ""
|
|
existing.CommercialMigration = nil
|
|
existing.PlanVersion = string(subscriptionStateExpiredValue)
|
|
existing.SubscriptionState = subscriptionStateExpiredValue
|
|
existing.TrialEndsAt = nil
|
|
existing.TrialExtendedAt = nil
|
|
|
|
if err := billingStore.SaveBillingState(orgID, existing); err != nil {
|
|
log.Warn().Err(err).Str("org_id", orgID).Msg("Failed to clear billing-backed entitlement state")
|
|
}
|
|
}
|
|
}
|
|
|
|
log.Info().Msg("Pulse Pro license cleared")
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"success": true,
|
|
"message": "License cleared",
|
|
})
|
|
}
|
|
|
|
// RequireLicenseFeature is a middleware that checks if a license feature is available.
|
|
// Returns HTTP 402 Payment Required if the feature is not licensed.
|
|
// WriteLicenseRequired writes a 402 Payment Required response for a missing license feature.
|
|
// ALL license gate responses in handlers MUST use this function to ensure consistent response format.
|
|
//
|
|
// NOTE: Direct 402 responses are intentionally centralized in this file to keep API behavior consistent.
|
|
func writePaymentRequired(w http.ResponseWriter, payload map[string]interface{}) {
|
|
writePaymentRequiredFromLicensing(w, payload)
|
|
}
|
|
|
|
func WriteLicenseRequired(w http.ResponseWriter, feature, message string) {
|
|
writeLicenseRequiredFromLicensing(w, feature, message)
|
|
}
|
|
|
|
// RequireLicenseFeature is a middleware that checks if a license feature is available.
|
|
// Returns HTTP 402 Payment Required if the feature is not licensed.
|
|
// Note: Changed to take *LicenseHandlers to access service at runtime.
|
|
func RequireLicenseFeature(resolver licenseFeatureServiceResolver, feature string, next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if resolver == nil {
|
|
WriteLicenseRequired(w, feature, "license service unavailable")
|
|
return
|
|
}
|
|
service := resolver.FeatureService(r.Context())
|
|
if service == nil {
|
|
WriteLicenseRequired(w, feature, "license service unavailable")
|
|
return
|
|
}
|
|
if err := service.RequireFeature(feature); err != nil {
|
|
WriteLicenseRequired(w, feature, err.Error())
|
|
return
|
|
}
|
|
next(w, r)
|
|
}
|
|
}
|
|
|
|
// LicenseGatedEmptyResponse returns an empty array with license metadata header for unlicensed users.
|
|
// Use this instead of RequireLicenseFeature when the endpoint should return empty data
|
|
// rather than a 402 error (to avoid breaking Promise.all in the frontend).
|
|
// The X-License-Required header indicates upgrade is needed.
|
|
// LicenseGatedEmptyResponse returns an empty array with license metadata header for unlicensed users.
|
|
// Use this instead of RequireLicenseFeature when the endpoint should return empty data
|
|
// rather than a 402 error (to avoid breaking Promise.all in the frontend).
|
|
// The X-License-Required header indicates upgrade is needed.
|
|
func LicenseGatedEmptyResponse(resolver licenseFeatureServiceResolver, feature string, next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if resolver == nil {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("X-License-Required", "true")
|
|
w.Header().Set("X-License-Feature", feature)
|
|
w.Write([]byte("[]"))
|
|
return
|
|
}
|
|
service := resolver.FeatureService(r.Context())
|
|
if service == nil || service.RequireFeature(feature) != nil {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
// Set header to indicate license is required (frontend can check this)
|
|
w.Header().Set("X-License-Required", "true")
|
|
w.Header().Set("X-License-Feature", feature)
|
|
// Return 200 with empty array (compatible with frontend array expectations)
|
|
w.Write([]byte("[]"))
|
|
return
|
|
}
|
|
next(w, r)
|
|
}
|
|
}
|