mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-07 00:37:36 +00:00
1920 lines
75 KiB
Go
1920 lines
75 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"html"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strconv"
|
|
"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/rs/zerolog/log"
|
|
)
|
|
|
|
const (
|
|
trialStartRateLimitBurst = 6
|
|
trialStartRateLimitWindow = 15 * time.Minute
|
|
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"
|
|
purchaseReturnKeyPurpose = "pulse-license-purchase-return"
|
|
)
|
|
|
|
// revocationFeedToken returns the relay feed token for revocation polling.
|
|
// Empty string means revocation polling is disabled.
|
|
func revocationFeedToken() string {
|
|
return os.Getenv("PULSE_REVOCATION_FEED_TOKEN")
|
|
}
|
|
|
|
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
|
|
trialLimiter *RateLimiter
|
|
trialReplay *jtiReplayStore
|
|
purchaseReturnRedemptions *purchaseReturnRedemptionStore
|
|
trialInitiations *trialSignupInitiationStore
|
|
trialRedeemer func(token string) (*hostedTrialRedemptionResponse, error)
|
|
monitor *monitoring.Monitor
|
|
mtMonitor *monitoring.MultiTenantMonitor
|
|
conversionRecorder *conversionRecorder
|
|
conversionHealth *conversionPipelineHealth
|
|
hostedLeaseRefresh sync.Map // map[string]*hostedEntitlementRefreshLoop
|
|
legacyGrandfatherReconcile sync.Map // map[string]*legacyGrandfatherReconcileLoop
|
|
}
|
|
|
|
// 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 trialReplay *jtiReplayStore
|
|
var purchaseReturnRedemptions *purchaseReturnRedemptionStore
|
|
var trialInitiations *trialSignupInitiationStore
|
|
if mtp != nil {
|
|
trialReplay = &jtiReplayStore{configDir: mtp.BaseDataDir()}
|
|
purchaseReturnRedemptions = &purchaseReturnRedemptionStore{configDir: mtp.BaseDataDir()}
|
|
trialInitiations = &trialSignupInitiationStore{configDir: mtp.BaseDataDir()}
|
|
}
|
|
|
|
return &LicenseHandlers{
|
|
mtPersistence: mtp,
|
|
hostedMode: hostedMode,
|
|
cfg: cfg,
|
|
trialLimiter: NewRateLimiter(trialStartRateLimitBurst, trialStartRateLimitWindow),
|
|
trialReplay: trialReplay,
|
|
purchaseReturnRedemptions: purchaseReturnRedemptions,
|
|
trialInitiations: trialInitiations,
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// SetConversionRecorder wires the conversion event recorder for backend-emitted
|
|
// conversion events (trial_started, license_activated, license_activation_failed).
|
|
func (h *LicenseHandlers) SetConversionRecorder(rec *conversionRecorder, health *conversionPipelineHealth) {
|
|
if h == nil {
|
|
return
|
|
}
|
|
h.conversionRecorder = rec
|
|
h.conversionHealth = health
|
|
}
|
|
|
|
// emitConversionEvent is a fire-and-forget helper that records a backend-emitted
|
|
// conversion event. Respects the DisableLocalUpgradeMetrics config flag.
|
|
// Errors are logged but never propagated to callers.
|
|
func (h *LicenseHandlers) emitConversionEvent(orgID string, event conversionEvent) {
|
|
if h == nil || h.conversionRecorder == nil {
|
|
return
|
|
}
|
|
if h.cfg != nil && h.cfg.DisableLocalUpgradeMetrics {
|
|
return
|
|
}
|
|
if orgID == "" {
|
|
orgID = "default"
|
|
}
|
|
event.OrgID = orgID
|
|
if event.Timestamp <= 0 {
|
|
event.Timestamp = time.Now().UnixMilli()
|
|
}
|
|
if event.IdempotencyKey == "" {
|
|
event.IdempotencyKey = fmt.Sprintf("backend:%s:%s:%s:%d", orgID, event.Type, event.Surface, event.Timestamp)
|
|
}
|
|
if err := h.conversionRecorder.Record(event); err != nil {
|
|
log.Warn().Err(err).Str("event_type", event.Type).Str("org_id", orgID).Msg("Failed to record backend conversion event")
|
|
} else {
|
|
recordConversionEventMetric(event.Type, event.Surface)
|
|
if h.conversionHealth != nil {
|
|
h.conversionHealth.RecordEvent(event.Type)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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.legacyGrandfatherReconcile.Range(func(key, value any) bool {
|
|
if orgID, ok := key.(string); ok {
|
|
h.stopLegacyGrandfatherReconcileLoop(orgID)
|
|
}
|
|
return true
|
|
})
|
|
h.services.Range(func(_, value any) bool {
|
|
if svc, ok := value.(*licenseService); ok {
|
|
svc.StopGrantRefresh()
|
|
svc.StopRevocationPoll()
|
|
}
|
|
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 trialCallbackURLForRequest(r *http.Request, cfg *config.Config) string {
|
|
baseURL := publicBaseURLForRequest(r, cfg)
|
|
if baseURL == "" {
|
|
return ""
|
|
}
|
|
return baseURL + "/auth/trial-activate"
|
|
}
|
|
|
|
func licensePurchaseCallbackURLForRequest(r *http.Request, cfg *config.Config) string {
|
|
baseURL := publicBaseURLForRequest(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 trialSignupActionURLForRequest(cfg *config.Config, orgID, returnURL, instanceToken string) (string, error) {
|
|
signupBaseURL := ""
|
|
if cfg != nil {
|
|
signupBaseURL = strings.TrimSpace(cfg.ProTrialSignupURL)
|
|
}
|
|
signupURL := strings.TrimSpace(proTrialSignupURLFromLicensing(signupBaseURL))
|
|
if signupURL == "" {
|
|
return "", fmt.Errorf("trial signup URL is unavailable")
|
|
}
|
|
|
|
parsed, err := url.Parse(signupURL)
|
|
if err != nil {
|
|
return "", fmt.Errorf("parse trial signup URL: %w", err)
|
|
}
|
|
|
|
query := parsed.Query()
|
|
query.Set("org_id", strings.TrimSpace(orgID))
|
|
query.Set("return_url", strings.TrimSpace(returnURL))
|
|
query.Set("instance_token", strings.TrimSpace(instanceToken))
|
|
parsed.RawQuery = query.Encode()
|
|
|
|
return parsed.String(), 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 := publicBaseURLForRequest(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 licensePurchaseActivationRedirectPath(feature, purchaseResult string) string {
|
|
normalizedFeature := strings.TrimSpace(feature)
|
|
query := url.Values{}
|
|
switch normalizedFeature {
|
|
case "max_monitored_systems":
|
|
query.Set("intent", "max_monitored_systems")
|
|
}
|
|
switch strings.TrimSpace(purchaseResult) {
|
|
case selfHostedBillingPurchaseActivated,
|
|
selfHostedBillingPurchaseCancelled,
|
|
selfHostedBillingPurchaseExpired,
|
|
selfHostedBillingPurchaseFailed:
|
|
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>Pulse Pro 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\">Pulse Pro activation needs attention</h1><p id=\"purchase-return-status\" style=\"margin-bottom:1rem\">%s</p><p><a href=\"%s\">Open Pulse Pro billing</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='Pulse Pro billing has been reopened in the original tab. You can close this window if it stays open.';}},75);return;}if(statusEl){statusEl.textContent='Returning to Pulse Pro billing.';}setTimeout(function(){window.location.replace(redirectPath);},150);}());</script></body></html>",
|
|
escapedMessage,
|
|
escapedLink,
|
|
redirectPath,
|
|
strings.TrimSpace(purchaseResult),
|
|
)
|
|
}
|
|
|
|
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>Pulse Pro 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\">Pulse Pro activated</h1><p id=\"purchase-return-status\" style=\"margin-bottom:1rem\">Pulse is refreshing billing in the original tab.</p><p><a href=\"%s\">Return to Pulse Pro billing</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='Pulse Pro is ready. You can close this tab if it stays open.';}},75);return;}if(statusEl){statusEl.textContent='Pulse Pro is ready. Returning to billing.';}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 Pulse Pro 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 Pulse Pro 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 Pulse Pro</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 billing.';}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)
|
|
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()
|
|
h.bindLegacyGrandfatherReconcileOwnership(orgID, service)
|
|
|
|
// 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 {
|
|
h.reconcileLegacyMigrationGrandfatherFloor(ctx, orgID, service)
|
|
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())
|
|
// Start revocation polling if a feed token is configured.
|
|
if feedToken := revocationFeedToken(); feedToken != "" {
|
|
service.StartRevocationPoll(context.Background(), feedToken)
|
|
}
|
|
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")
|
|
}
|
|
} else if strings.TrimSpace(legacyJWT) != "" {
|
|
if _, err := service.Activate(legacyJWT); err != nil {
|
|
if persistErr := h.setCommercialMigrationState(orgID, classifyLegacyExchangeErrorFromLicensing(err)); 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")
|
|
} else if service.IsActivated() {
|
|
h.reconcileLegacyMigrationGrandfatherFloor(ctx, orgID, service)
|
|
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())
|
|
if feedToken := revocationFeedToken(); feedToken != "" {
|
|
service.StartRevocationPoll(context.Background(), feedToken)
|
|
}
|
|
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.StopRevocationPoll() // stop our orphaned revocation poller if started
|
|
svc := actual.(*licenseService)
|
|
// Re-home legacy continuity ownership onto the canonical service when
|
|
// concurrent first-request initialization races create an orphan.
|
|
h.stopLegacyGrandfatherReconcileLoop(orgID)
|
|
h.syncLegacyGrandfatherReconcileOwnership(orgID, svc)
|
|
h.ensureHostedEntitlementRefreshForOrg(orgID, svc)
|
|
p, pErr := h.getPersistenceForOrg(orgID)
|
|
h.syncReleaseDemoFixtureRuntime(orgID, svc)
|
|
return svc, p, pErr
|
|
}
|
|
|
|
h.syncLegacyGrandfatherReconcileOwnership(orgID, service)
|
|
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) canonicalMonitoredSystemGrandfatherFloor(ctx context.Context) (int, bool) {
|
|
usage := h.entitlementUsageSnapshot(ctx)
|
|
if !usage.MonitoredSystemsAvailable {
|
|
return 0, false
|
|
}
|
|
if usage.MonitoredSystems < 0 {
|
|
return 0, false
|
|
}
|
|
return int(usage.MonitoredSystems), true
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
existing.CommercialMigration = cloneCommercialMigrationStatusFromLicensing(status)
|
|
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)
|
|
}
|
|
|
|
// HandleStartTrial handles POST /api/license/trial/start.
|
|
// SaaS trials are initiated through hosted signup; the local instance only redeems
|
|
// a signed activation token via /auth/trial-activate.
|
|
func (h *LicenseHandlers) HandleStartTrial(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if h == nil || h.mtPersistence == nil {
|
|
writeErrorResponse(w, http.StatusInternalServerError, "trial_start_unavailable", "Trial start is unavailable", nil)
|
|
return
|
|
}
|
|
|
|
orgID := GetOrgID(r.Context())
|
|
if orgID == "" {
|
|
orgID = "default"
|
|
}
|
|
|
|
svc, _, err := h.getTenantComponents(r.Context())
|
|
if err != nil {
|
|
writeErrorResponse(w, http.StatusInternalServerError, "tenant_error", "Failed to resolve tenant", nil)
|
|
return
|
|
}
|
|
|
|
billingStore := config.NewFileBillingStore(h.mtPersistence.BaseDataDir())
|
|
existing, err := billingStore.GetBillingState(orgID)
|
|
if err != nil {
|
|
writeErrorResponse(w, http.StatusInternalServerError, "billing_state_load_failed", "Failed to load billing state", nil)
|
|
return
|
|
}
|
|
decision := evaluateTrialStartEligibilityFromLicensing(svc.Current() != nil && svc.IsValid(), existing)
|
|
if !decision.Allowed {
|
|
code, message, includeOrgID := trialStartErrorFromLicensing(decision.Reason)
|
|
details := map[string]string(nil)
|
|
if includeOrgID {
|
|
details = map[string]string{"org_id": orgID}
|
|
}
|
|
writeErrorResponse(w, http.StatusConflict, code, message, details)
|
|
return
|
|
}
|
|
|
|
if h.trialLimiter != nil {
|
|
allowed, retryDelay := h.trialLimiter.allowAt(orgID, time.Now().UTC())
|
|
if !allowed {
|
|
retryAfterSeconds := int(retryDelay.Round(time.Second).Seconds())
|
|
if retryAfterSeconds < 1 {
|
|
retryAfterSeconds = 1
|
|
}
|
|
w.Header().Set("Retry-After", strconv.Itoa(retryAfterSeconds))
|
|
writeErrorResponse(w, http.StatusTooManyRequests, "trial_rate_limited", "Trial start rate limit exceeded", map[string]string{
|
|
"org_id": orgID,
|
|
"retry_after_seconds": strconv.Itoa(retryAfterSeconds),
|
|
})
|
|
return
|
|
}
|
|
}
|
|
|
|
if h.trialInitiations == nil {
|
|
writeErrorResponse(w, http.StatusServiceUnavailable, "trial_signup_unavailable", "Hosted trial signup is unavailable", nil)
|
|
return
|
|
}
|
|
returnURL := trialCallbackURLForRequest(r, h.cfg)
|
|
if returnURL == "" {
|
|
log.Error().Str("org_id", orgID).Msg("Trial callback URL unavailable for initiation")
|
|
writeErrorResponse(w, http.StatusServiceUnavailable, "trial_signup_unavailable", "Hosted trial signup is unavailable", nil)
|
|
return
|
|
}
|
|
instanceToken, err := h.trialInitiations.issue(orgID, returnURL, time.Now().UTC().Add(trialSignupInitiationTTL))
|
|
if err != nil {
|
|
log.Error().Err(err).Str("org_id", orgID).Msg("Trial signup initiation token unavailable")
|
|
writeErrorResponse(w, http.StatusServiceUnavailable, "trial_signup_unavailable", "Hosted trial signup is unavailable", nil)
|
|
return
|
|
}
|
|
|
|
actionURL, err := trialSignupActionURLForRequest(h.cfg, orgID, returnURL, instanceToken)
|
|
if err != nil {
|
|
log.Error().Err(err).Str("org_id", orgID).Msg("Trial signup redirect unavailable")
|
|
writeErrorResponse(w, http.StatusServiceUnavailable, "trial_signup_unavailable", "Hosted trial signup is unavailable", nil)
|
|
return
|
|
}
|
|
|
|
h.emitConversionEvent(orgID, conversionEvent{
|
|
Type: conversionEventCheckoutStarted,
|
|
Surface: "license_api",
|
|
})
|
|
|
|
writeErrorResponse(w, http.StatusConflict, "trial_signup_required", "Complete hosted signup to start your trial", map[string]string{
|
|
"org_id": orgID,
|
|
"action_url": actionURL,
|
|
})
|
|
}
|
|
|
|
// HandleTrialActivation handles GET /auth/trial-activate.
|
|
// It verifies a hosted signup trial activation token, blocks replay, persists a
|
|
// signed hosted entitlement lease, then redirects to Settings.
|
|
func (h *LicenseHandlers) HandleTrialActivation(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if h == nil || h.mtPersistence == nil {
|
|
http.Redirect(w, r, trialActivationResultURL("unavailable"), http.StatusTemporaryRedirect)
|
|
return
|
|
}
|
|
|
|
token := strings.TrimSpace(r.URL.Query().Get("token"))
|
|
if token == "" {
|
|
http.Redirect(w, r, trialActivationResultURL("invalid"), http.StatusTemporaryRedirect)
|
|
return
|
|
}
|
|
|
|
publicKey, err := trialActivationPublicKeyFromLicensing()
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Trial activation public key not configured")
|
|
http.Redirect(w, r, trialActivationResultURL("unavailable"), http.StatusTemporaryRedirect)
|
|
return
|
|
}
|
|
|
|
// Prefer configured PublicURL for host binding validation.
|
|
// Fall back to request Host only if no PublicURL is configured, to avoid
|
|
// an attacker-controlled Host header weakening instance binding.
|
|
// If PublicURL is set but normalizes to empty (malformed), fail closed.
|
|
expectedHost := ""
|
|
if h.cfg != nil && strings.TrimSpace(h.cfg.PublicURL) != "" {
|
|
expectedHost = normalizeHostForTrial(h.cfg.PublicURL)
|
|
if expectedHost == "" {
|
|
log.Error().Msg("PublicURL configured but could not extract host for trial activation binding")
|
|
http.Redirect(w, r, trialActivationResultURL("unavailable"), http.StatusTemporaryRedirect)
|
|
return
|
|
}
|
|
} else {
|
|
expectedHost = normalizeHostForTrial(r.Host)
|
|
}
|
|
if expectedHost == "" {
|
|
log.Error().Msg("Could not determine host for trial activation binding")
|
|
http.Redirect(w, r, trialActivationResultURL("unavailable"), http.StatusTemporaryRedirect)
|
|
return
|
|
}
|
|
claims, err := verifyTrialActivationTokenFromLicensing(token, publicKey, expectedHost, time.Now().UTC())
|
|
if err != nil {
|
|
log.Warn().Err(err).Msg("Trial activation token verification failed")
|
|
http.Redirect(w, r, trialActivationResultURL("invalid"), http.StatusTemporaryRedirect)
|
|
return
|
|
}
|
|
|
|
replayStore := h.trialReplay
|
|
if replayStore == nil {
|
|
replayStore = &jtiReplayStore{configDir: h.mtPersistence.BaseDataDir()}
|
|
}
|
|
replaySubject := strings.TrimSpace(claims.Subject)
|
|
if replaySubject == "" {
|
|
replaySubject = strings.TrimSpace(claims.ID)
|
|
}
|
|
if replaySubject == "" {
|
|
log.Warn().Msg("Trial activation token missing subject and jti")
|
|
http.Redirect(w, r, trialActivationResultURL("invalid"), http.StatusTemporaryRedirect)
|
|
return
|
|
}
|
|
replayID := "trial_activate:" + replaySubject
|
|
expiresAt := time.Now().UTC().Add(15 * time.Minute)
|
|
if claims.ExpiresAt != nil {
|
|
expiresAt = claims.ExpiresAt.Time
|
|
}
|
|
stored, err := replayStore.checkAndStore(replayID, expiresAt)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Trial activation replay-store failure")
|
|
http.Redirect(w, r, trialActivationResultURL("unavailable"), http.StatusTemporaryRedirect)
|
|
return
|
|
}
|
|
if !stored {
|
|
log.Warn().Str("replay_id_prefix", replayID[:24]).Msg("Trial activation token replay blocked")
|
|
http.Redirect(w, r, trialActivationResultURL("replayed"), http.StatusTemporaryRedirect)
|
|
return
|
|
}
|
|
clearReplay := func(reason string, err error) {
|
|
if replayStore == nil {
|
|
return
|
|
}
|
|
if deleteErr := replayStore.delete(replayID); deleteErr != nil {
|
|
log.Warn().Err(deleteErr).Str("replay_id_prefix", replayID[:24]).Str("reason", reason).Msg("Trial activation replay-store cleanup failed")
|
|
return
|
|
}
|
|
if err != nil {
|
|
log.Warn().Err(err).Str("replay_id_prefix", replayID[:24]).Str("reason", reason).Msg("Cleared trial activation replay marker after transient failure")
|
|
}
|
|
}
|
|
|
|
orgID := strings.TrimSpace(claims.OrgID)
|
|
if orgID == "" {
|
|
orgID = "default"
|
|
}
|
|
if h.trialInitiations == nil {
|
|
clearReplay("initiation_store_unavailable", nil)
|
|
http.Redirect(w, r, trialActivationResultURL("unavailable"), http.StatusTemporaryRedirect)
|
|
return
|
|
}
|
|
returnURL := strings.TrimSpace(claims.ReturnURL)
|
|
ok, err := h.trialInitiations.validate(orgID, returnURL, claims.InstanceToken, time.Now().UTC())
|
|
if err != nil {
|
|
clearReplay("initiation_validation_failed", err)
|
|
log.Error().Err(err).Str("org_id", orgID).Msg("Trial activation initiation token validation failed")
|
|
http.Redirect(w, r, trialActivationResultURL("unavailable"), http.StatusTemporaryRedirect)
|
|
return
|
|
}
|
|
if !ok {
|
|
log.Warn().Str("org_id", orgID).Msg("Trial activation missing or invalid initiation token")
|
|
http.Redirect(w, r, trialActivationResultURL("invalid"), http.StatusTemporaryRedirect)
|
|
return
|
|
}
|
|
|
|
ctx := context.WithValue(r.Context(), OrgIDContextKey, orgID)
|
|
svc, _, err := h.getTenantComponents(ctx)
|
|
if err != nil {
|
|
clearReplay("tenant_resolution_failed", err)
|
|
log.Error().Err(err).Str("org_id", orgID).Msg("Trial activation tenant resolution failed")
|
|
http.Redirect(w, r, trialActivationResultURL("unavailable"), http.StatusTemporaryRedirect)
|
|
return
|
|
}
|
|
|
|
billingStore := config.NewFileBillingStore(h.mtPersistence.BaseDataDir())
|
|
existing, err := billingStore.GetBillingState(orgID)
|
|
if err != nil {
|
|
clearReplay("billing_state_load_failed", err)
|
|
log.Error().Err(err).Str("org_id", orgID).Msg("Trial activation billing state load failed")
|
|
http.Redirect(w, r, trialActivationResultURL("unavailable"), http.StatusTemporaryRedirect)
|
|
return
|
|
}
|
|
decision := evaluateTrialStartEligibilityFromLicensing(svc.Current() != nil && svc.IsValid(), existing)
|
|
if !decision.Allowed {
|
|
log.Info().
|
|
Str("org_id", orgID).
|
|
Str("reason", string(decision.Reason)).
|
|
Msg("Trial activation denied due to ineligible state")
|
|
http.Redirect(w, r, trialActivationResultURL("ineligible"), http.StatusTemporaryRedirect)
|
|
return
|
|
}
|
|
|
|
redeemer := h.trialRedeemer
|
|
if redeemer == nil {
|
|
redeemer = h.acknowledgeHostedTrialRedemption
|
|
}
|
|
var redemption *hostedTrialRedemptionResponse
|
|
if redeemer != nil {
|
|
redemption, err = redeemer(token)
|
|
if err != nil {
|
|
clearReplay("redemption_ack_failed", err)
|
|
log.Warn().Err(err).Str("org_id", orgID).Msg("Hosted trial redemption acknowledgement failed")
|
|
http.Redirect(w, r, trialActivationResultURL("unavailable"), http.StatusTemporaryRedirect)
|
|
return
|
|
}
|
|
}
|
|
entitlementJWT := ""
|
|
entitlementRefreshToken := ""
|
|
if redemption != nil {
|
|
entitlementJWT = strings.TrimSpace(redemption.EntitlementJWT)
|
|
entitlementRefreshToken = strings.TrimSpace(redemption.EntitlementRefreshToken)
|
|
}
|
|
if strings.TrimSpace(entitlementJWT) == "" {
|
|
clearReplay("entitlement_lease_missing", nil)
|
|
log.Warn().Str("org_id", orgID).Msg("Hosted trial redemption returned no entitlement lease")
|
|
http.Redirect(w, r, trialActivationResultURL("unavailable"), http.StatusTemporaryRedirect)
|
|
return
|
|
}
|
|
if entitlementRefreshToken == "" {
|
|
clearReplay("entitlement_refresh_token_missing", nil)
|
|
log.Warn().Str("org_id", orgID).Msg("Hosted trial redemption returned no entitlement refresh token")
|
|
http.Redirect(w, r, trialActivationResultURL("unavailable"), http.StatusTemporaryRedirect)
|
|
return
|
|
}
|
|
leaseClaims, err := verifyEntitlementLeaseTokenFromLicensing(entitlementJWT, publicKey, expectedHost, time.Now().UTC())
|
|
if err != nil {
|
|
clearReplay("entitlement_lease_invalid", err)
|
|
log.Warn().Err(err).Str("org_id", orgID).Msg("Hosted trial entitlement lease verification failed")
|
|
http.Redirect(w, r, trialActivationResultURL("unavailable"), http.StatusTemporaryRedirect)
|
|
return
|
|
}
|
|
if strings.TrimSpace(leaseClaims.OrgID) != orgID {
|
|
clearReplay("entitlement_lease_org_mismatch", nil)
|
|
log.Warn().
|
|
Str("org_id", orgID).
|
|
Str("lease_org_id", strings.TrimSpace(leaseClaims.OrgID)).
|
|
Msg("Hosted trial entitlement lease org mismatch")
|
|
http.Redirect(w, r, trialActivationResultURL("invalid"), http.StatusTemporaryRedirect)
|
|
return
|
|
}
|
|
|
|
state := &billingState{}
|
|
if existing != nil {
|
|
state = normalizeBillingStateFromLicensing(existing)
|
|
}
|
|
// Keep only the signed hosted lease plus trial-used bookkeeping locally.
|
|
// Effective Pro capabilities and limits must resolve from the lease on read.
|
|
state.EntitlementJWT = entitlementJWT
|
|
state.EntitlementRefreshToken = entitlementRefreshToken
|
|
state.Capabilities = []string{}
|
|
state.Limits = map[string]int64{}
|
|
state.MetersEnabled = []string{}
|
|
state.PlanVersion = ""
|
|
state.SubscriptionState = ""
|
|
state.TrialStartedAt = leaseClaims.TrialStartedAt
|
|
state.TrialEndsAt = nil
|
|
state.TrialExtendedAt = nil
|
|
if err := billingStore.SaveBillingState(orgID, state); err != nil {
|
|
clearReplay("billing_state_save_failed", err)
|
|
log.Error().Err(err).Str("org_id", orgID).Msg("Trial activation billing state save failed")
|
|
http.Redirect(w, r, trialActivationResultURL("unavailable"), http.StatusTemporaryRedirect)
|
|
return
|
|
}
|
|
if svc.Current() == nil {
|
|
eval := newLicenseEvaluatorForBillingStoreFromLicensing(billingStore, orgID, 0, expectedHost)
|
|
svc.SetEvaluator(eval)
|
|
}
|
|
h.ensureHostedEntitlementRefreshForOrg(orgID, svc)
|
|
|
|
isSecure, sameSite := getCookieSettings(r)
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: CookieNameOrgID,
|
|
Value: orgID,
|
|
Path: "/",
|
|
Secure: isSecure,
|
|
SameSite: sameSite,
|
|
MaxAge: int((24 * time.Hour).Seconds()),
|
|
})
|
|
|
|
h.emitConversionEvent(orgID, conversionEvent{
|
|
Type: conversionEventTrialStarted,
|
|
Surface: "hosted_signup",
|
|
})
|
|
|
|
if consumed, consumeErr := h.trialInitiations.consume(orgID, returnURL, claims.InstanceToken, time.Now().UTC()); consumeErr != nil {
|
|
log.Warn().Err(consumeErr).Str("org_id", orgID).Msg("Trial initiation token consume failed after activation")
|
|
} else if !consumed {
|
|
log.Warn().Str("org_id", orgID).Msg("Trial initiation token was not consumed after activation")
|
|
}
|
|
|
|
log.Info().
|
|
Str("org_id", orgID).
|
|
Str("email", strings.TrimSpace(claims.Email)).
|
|
Msg("Trial activation succeeded")
|
|
|
|
http.Redirect(w, r, trialActivationResultURL("activated"), http.StatusTemporaryRedirect)
|
|
}
|
|
|
|
func trialActivationResultURL(result string) string {
|
|
return "/settings/system-pro?trial=" + url.QueryEscape(strings.TrimSpace(result))
|
|
}
|
|
|
|
func normalizeTrialOrgID(raw string) string {
|
|
orgID := strings.TrimSpace(raw)
|
|
if orgID == "" {
|
|
return "default"
|
|
}
|
|
return orgID
|
|
}
|
|
|
|
type hostedTrialRedemptionResponse struct {
|
|
EntitlementJWT string `json:"entitlement_jwt"`
|
|
EntitlementRefreshToken string `json:"entitlement_refresh_token"`
|
|
}
|
|
|
|
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 (h *LicenseHandlers) acknowledgeHostedTrialRedemption(token string) (*hostedTrialRedemptionResponse, error) {
|
|
if h == nil {
|
|
return nil, nil
|
|
}
|
|
redemptionURL := trialSignupRedemptionURLFromConfig(h.cfg)
|
|
if redemptionURL == "" {
|
|
return nil, nil
|
|
}
|
|
payload, err := json.Marshal(map[string]string{"token": strings.TrimSpace(token)})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal trial redemption payload: %w", err)
|
|
}
|
|
req, err := http.NewRequest(http.MethodPost, redemptionURL, bytes.NewReader(payload))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("build trial redemption request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
client := &http.Client{Timeout: 5 * time.Second}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("post trial redemption acknowledgement: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return nil, fmt.Errorf("trial redemption acknowledgement returned status %d", resp.StatusCode)
|
|
}
|
|
var response hostedTrialRedemptionResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
|
|
return nil, fmt.Errorf("decode trial redemption acknowledgement: %w", err)
|
|
}
|
|
if strings.TrimSpace(response.EntitlementJWT) == "" {
|
|
return nil, fmt.Errorf("trial redemption acknowledgement missing entitlement_jwt")
|
|
}
|
|
if strings.TrimSpace(response.EntitlementRefreshToken) == "" {
|
|
return nil, fmt.Errorf("trial redemption acknowledgement missing entitlement_refresh_token")
|
|
}
|
|
return &response, nil
|
|
}
|
|
|
|
func trialSignupRedemptionURLFromConfig(cfg *config.Config) string {
|
|
signupBaseURL := ""
|
|
if cfg != nil {
|
|
signupBaseURL = strings.TrimSpace(cfg.ProTrialSignupURL)
|
|
}
|
|
signupURL := strings.TrimSpace(proTrialSignupURLFromLicensing(signupBaseURL))
|
|
if signupURL == "" {
|
|
return ""
|
|
}
|
|
parsed, err := url.Parse(signupURL)
|
|
if err != nil || parsed == nil {
|
|
return ""
|
|
}
|
|
parsed.Path = "/api/trial-signup/redeem"
|
|
parsed.RawQuery = ""
|
|
return parsed.String()
|
|
}
|
|
|
|
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 := url.Parse(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 "License verification is temporarily unavailable. Please try again later."
|
|
}
|
|
|
|
// 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 {
|
|
h.emitConversionEvent(orgID, conversionEvent{
|
|
Type: conversionEventLicenseActivationFailed,
|
|
Surface: "license_api",
|
|
})
|
|
return ActivateLicenseResponse{}, err
|
|
}
|
|
|
|
if service.IsActivated() {
|
|
if migratedLegacyKey {
|
|
h.reconcileLegacyMigrationGrandfatherFloor(ctx, orgID, service)
|
|
}
|
|
h.stopHostedEntitlementRefreshLoop(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())
|
|
if feedToken := revocationFeedToken(); feedToken != "" {
|
|
service.StartRevocationPoll(context.Background(), feedToken)
|
|
}
|
|
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)
|
|
|
|
h.emitConversionEvent(orgID, conversionEvent{
|
|
Type: conversionEventLicenseActivated,
|
|
Surface: "license_api",
|
|
})
|
|
|
|
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
|
|
}
|
|
|
|
expectedHost := purchaseReturnExpectedHost(r, h.cfg)
|
|
returnURL := licensePurchaseCallbackURLForRequest(r, h.cfg)
|
|
if expectedHost == "" || returnURL == "" {
|
|
http.Error(w, "Pulse Account handoff unavailable", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
signingKey, err := h.purchaseReturnSigningKey()
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Purchase return signing key unavailable")
|
|
http.Error(w, "Pulse Account handoff unavailable", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
orgID := strings.TrimSpace(GetOrgID(r.Context()))
|
|
if orgID == "" {
|
|
orgID = "default"
|
|
}
|
|
feature := strings.TrimSpace(r.URL.Query().Get(pulseAccountPortalFeatureQueryParam))
|
|
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")
|
|
http.Error(w, "Pulse Account handoff unavailable", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
activationURLTemplate, err := licensePurchaseActivationTemplateURL(returnURL, returnToken)
|
|
if err != nil {
|
|
log.Error().Err(err).Str("feature", feature).Msg("Failed to build Pulse Account activation template")
|
|
http.Error(w, "Pulse Account handoff unavailable", http.StatusServiceUnavailable)
|
|
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")
|
|
http.Error(w, "Pulse Account handoff unavailable", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
lsClient := newLicenseServerClientFromLicensing("")
|
|
if lsClient == nil {
|
|
http.Error(w, "Pulse Account handoff unavailable", http.StatusServiceUnavailable)
|
|
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")
|
|
http.Error(w, "Pulse Account handoff unavailable", http.StatusServiceUnavailable)
|
|
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")
|
|
http.Error(w, "Pulse Account handoff unavailable", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
portalHandoffID := strings.TrimSpace(portalHandoff.PortalHandoffID)
|
|
if portalHandoffID == "" {
|
|
log.Error().Str("feature", feature).Msg("Pulse Account checkout portal handoff response omitted id")
|
|
http.Error(w, "Pulse Account handoff unavailable", http.StatusServiceUnavailable)
|
|
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")
|
|
http.Error(w, "Pulse Account handoff unavailable", http.StatusServiceUnavailable)
|
|
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 := strings.TrimSpace(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 Pulse Pro billing.")
|
|
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 Pulse Pro billing.")
|
|
return
|
|
}
|
|
if claims != nil && strings.TrimSpace(claims.Feature) != "" {
|
|
feature = strings.TrimSpace(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 := strings.TrimSpace(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 Pulse Pro billing.")
|
|
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 Pulse Pro billing.")
|
|
return
|
|
}
|
|
if claims != nil && strings.TrimSpace(claims.Feature) != "" {
|
|
if feature != "" && feature != strings.TrimSpace(claims.Feature) {
|
|
writeLicensePurchaseActivationFailurePage(w, http.StatusBadRequest, strings.TrimSpace(claims.Feature), selfHostedBillingPurchaseExpired, "Purchase activation state did not match the completed upgrade flow.")
|
|
return
|
|
}
|
|
feature = strings.TrimSpace(claims.Feature)
|
|
}
|
|
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 Pulse Pro billing.")
|
|
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 Pulse Pro billing.")
|
|
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 Pulse Pro billing.")
|
|
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 Pulse Pro. Reopen billing 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 billing 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 Pulse Pro billing.")
|
|
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.stopLegacyGrandfatherReconcileLoop(orgID)
|
|
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)
|
|
}
|
|
}
|