Pulse/internal/api/licensing_handlers.go
2026-03-18 16:06:30 +00:00

1237 lines
44 KiB
Go

package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
"github.com/rs/zerolog/log"
)
// 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")
}
// 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
trialInitiations *trialSignupInitiationStore
trialRedeemer func(token string) (*hostedTrialRedemptionResponse, error)
monitor *monitoring.Monitor
mtMonitor *monitoring.MultiTenantMonitor
conversionRecorder *conversionRecorder
conversionHealth *conversionPipelineHealth
hostedLeaseRefresh sync.Map // map[string]*hostedEntitlementRefreshLoop
}
// 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 trialInitiations *trialSignupInitiationStore
if mtp != nil {
trialReplay = &jtiReplayStore{configDir: mtp.BaseDataDir()}
trialInitiations = &trialSignupInitiationStore{configDir: mtp.BaseDataDir()}
}
return &LicenseHandlers{
mtPersistence: mtp,
hostedMode: hostedMode,
cfg: cfg,
trialLimiter: NewRateLimiter(1, 24*time.Hour), // 1 trial start attempt per org per 24h
trialReplay: trialReplay,
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.services.Range(func(_, value any) bool {
if svc, ok := value.(*licenseService); ok {
svc.StopGrantRefresh()
svc.StopRevocationPoll()
}
return true
})
}
func trialCallbackURLForRequest(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), "/")
if baseURL == "" {
return ""
}
return baseURL + "/auth/trial-activate"
}
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 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)
return svc, p, err
}
// Initialize for this tenant
persistence, err := h.getPersistenceForOrg(orgID)
if err != nil {
return nil, nil, err
}
service := newLicenseService()
// 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())
// 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() {
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)
h.ensureHostedEntitlementRefreshForOrg(orgID, svc)
p, pErr := h.getPersistenceForOrg(orgID)
return svc, p, pErr
}
h.ensureHostedEntitlementRefreshForOrg(orgID, service)
return service, persistence, nil
}
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 && !h.trialLimiter.Allow(orgID) {
w.Header().Set("Retry-After", "86400")
writeErrorResponse(w, http.StatusTooManyRequests, "trial_rate_limited", "Trial start rate limit exceeded", map[string]string{
"org_id": orgID,
})
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) 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 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
}
// Activate the license
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
}
orgID := GetOrgID(r.Context())
migratedLegacyKey := !strings.HasPrefix(strings.TrimSpace(req.LicenseKey), activationKeyPrefixValue)
lic, err := service.Activate(req.LicenseKey)
if err != nil {
log.Warn().Err(err).Msg("Failed to activate license")
h.emitConversionEvent(orgID, conversionEvent{
Type: conversionEventLicenseActivationFailed,
Surface: "license_api",
})
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(ActivateLicenseResponse{
Success: false,
Message: userFriendlyActivationError(err),
})
return
}
// Persist based on activation type.
if service.IsActivated() {
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)
}
// Preserve migrated v5 keys for downgrade/recovery, but remove stale
// legacy persistence after a native v6 activation-key activation.
if persistence != nil {
if strings.HasPrefix(strings.TrimSpace(req.LicenseKey), activationKeyPrefixValue) {
_ = persistence.Delete()
} else if err := persistence.Save(req.LicenseKey); 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.emitConversionEvent(orgID, conversionEvent{
Type: conversionEventLicenseActivated,
Surface: "license_api",
})
successMessage := "License activated successfully"
if migratedLegacyKey && service.IsActivated() {
successMessage = "Pulse v5 license migrated and activated successfully"
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(ActivateLicenseResponse{
Success: true,
Message: successMessage,
Status: service.Status(),
})
}
// 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()
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)
}
}