mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-21 10:43:36 +00:00
3badd0a840
In dev/demo builds HasFeature returned the implicit dev grant verdict unconditionally, so features excluded from that grant (white_label, multi_user, unlimited, env-gated multi_tenant) were denied even when an explicitly activated, signature-verified license carried them - while /api/license/status reported them active from the same claims. That inconsistency made branded report rendering impossible to exercise in dev builds. Excluded features now fall through to real entitlement evaluation, so an activated license behaves the same in dev as in release builds; the no-license dev posture is unchanged.
1007 lines
29 KiB
Go
1007 lines
29 KiB
Go
package licensing
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Public key for license validation (Ed25519).
|
|
// This will be embedded at build time or set via SetPublicKey.
|
|
// For development, leave empty to skip validation.
|
|
var (
|
|
publicKeyMu sync.RWMutex
|
|
publicKey ed25519.PublicKey
|
|
)
|
|
|
|
// SetPublicKey sets the public key for license validation.
|
|
// This should be called during initialization with the production key.
|
|
func SetPublicKey(key ed25519.PublicKey) {
|
|
publicKeyMu.Lock()
|
|
defer publicKeyMu.Unlock()
|
|
|
|
if len(key) == 0 {
|
|
publicKey = nil
|
|
return
|
|
}
|
|
|
|
keyCopy := make(ed25519.PublicKey, len(key))
|
|
copy(keyCopy, key)
|
|
publicKey = keyCopy
|
|
}
|
|
|
|
func currentPublicKey() ed25519.PublicKey {
|
|
publicKeyMu.RLock()
|
|
defer publicKeyMu.RUnlock()
|
|
|
|
if len(publicKey) == 0 {
|
|
return nil
|
|
}
|
|
|
|
keyCopy := make(ed25519.PublicKey, len(publicKey))
|
|
copy(keyCopy, publicKey)
|
|
return keyCopy
|
|
}
|
|
|
|
// License errors
|
|
var (
|
|
ErrInvalidLicense = errors.New("invalid license key")
|
|
ErrExpiredLicense = errors.New("license has expired")
|
|
ErrMalformedLicense = errors.New("malformed license key")
|
|
ErrSignatureInvalid = errors.New("license signature invalid")
|
|
ErrFeatureNotIncluded = errors.New("feature not included in license")
|
|
ErrNoPublicKey = errors.New("no public key configured for validation")
|
|
)
|
|
|
|
// Service manages license validation and feature gating.
|
|
type Service struct {
|
|
mu sync.RWMutex
|
|
license *License
|
|
|
|
// Grace period duration when license validation fails
|
|
gracePeriod time.Duration
|
|
|
|
// Callback when license changes
|
|
onLicenseChange func(*License)
|
|
|
|
// Callback when activation state changes.
|
|
onActivationStateChange func(*ActivationState)
|
|
|
|
// Optional canonical evaluator for B2 entitlement checks.
|
|
// When set and no JWT license is active, HasFeature/Status/SubscriptionState
|
|
// delegate to evaluator primitives (capabilities/limits/subscription state).
|
|
// When nil, falls through to existing tier-based logic.
|
|
evaluator *Evaluator
|
|
|
|
// Optional subscription state machine hook.
|
|
// Only tracks whether a hook is configured; current derivation remains claim/license based.
|
|
stateMachineConfigured bool
|
|
|
|
// Activation-key license server fields.
|
|
serverClient *LicenseServerClient // HTTP client for license server
|
|
activationState *ActivationState // Current activation state (nil if using legacy JWT)
|
|
grantRefresh *grantRefreshLoop // Background refresh loop (nil until started)
|
|
revocationPoll *revocationPollLoop // Background revocation feed poller (nil until started)
|
|
|
|
// Persistence reference for activation state save/load. Set via SetPersistence.
|
|
persistence *Persistence
|
|
|
|
// Canonical runtime version supplied by the owning process. This is used for
|
|
// authenticated installation/version attribution on activation and refresh.
|
|
clientVersion string
|
|
|
|
// Canonical runtime build supplied by the owning process. This is separate
|
|
// from version so support can distinguish public/community binaries from
|
|
// private Pulse Pro binaries even when they share a release tag.
|
|
runtimeIdentity RuntimeIdentity
|
|
}
|
|
|
|
// DefaultGracePeriod is the duration after license expiration during which
|
|
// features remain available. All grace period logic MUST use this constant.
|
|
const DefaultGracePeriod = 7 * 24 * time.Hour
|
|
|
|
// NewService creates a new license service.
|
|
func NewService() *Service {
|
|
return &Service{
|
|
gracePeriod: DefaultGracePeriod,
|
|
runtimeIdentity: CommunityRuntimeIdentity(),
|
|
}
|
|
}
|
|
|
|
// ensureGracePeriodEnd sets the grace period end time on the license if not already set.
|
|
// Must be called while holding s.mu.
|
|
func (s *Service) ensureGracePeriodEnd() {
|
|
if s.license != nil && s.license.GracePeriodEnd == nil {
|
|
gracePeriodEnd := time.Unix(s.license.Claims.ExpiresAt, 0).Add(s.gracePeriod)
|
|
s.license.GracePeriodEnd = &gracePeriodEnd
|
|
}
|
|
}
|
|
|
|
// SetLicenseChangeCallback sets a callback for license change events.
|
|
func (s *Service) SetLicenseChangeCallback(cb func(*License)) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.onLicenseChange = cb
|
|
}
|
|
|
|
// SetActivationStateChangeCallback sets a callback for activation-state changes.
|
|
func (s *Service) SetActivationStateChangeCallback(cb func(*ActivationState)) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.onActivationStateChange = cb
|
|
}
|
|
|
|
// SetEvaluator overrides the entitlement evaluator.
|
|
// In normal operation, Activate and Clear manage the evaluator automatically.
|
|
// This method exists for testing; production code should not call it directly.
|
|
func (s *Service) SetEvaluator(eval *Evaluator) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.evaluator = eval
|
|
}
|
|
|
|
// SetStateMachine sets the optional subscription state machine hook.
|
|
// This is nil-safe and does not alter feature entitlement behavior.
|
|
func (s *Service) SetStateMachine(sm any) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.stateMachineConfigured = sm != nil
|
|
}
|
|
|
|
// Evaluator returns the current evaluator, or nil if not set.
|
|
func (s *Service) Evaluator() *Evaluator {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.evaluator
|
|
}
|
|
|
|
// Activate validates and activates a license key.
|
|
// Activation keys (ppk_live_...) are routed to ActivateWithKey.
|
|
// Legacy JWT activation is only allowed in explicit dev mode for test fixtures.
|
|
func (s *Service) Activate(licenseKey string) (*License, error) {
|
|
licenseKey = strings.TrimSpace(licenseKey)
|
|
if strings.HasPrefix(licenseKey, ActivationKeyPrefix) {
|
|
return s.ActivateWithKey(licenseKey)
|
|
}
|
|
if !isLicenseValidationDevMode() {
|
|
if !looksLikeLegacyJWTLicense(licenseKey) {
|
|
return nil, fmt.Errorf("license key is not a supported v6 activation key or migratable v5 license")
|
|
}
|
|
return s.ActivateLegacyLicense(licenseKey)
|
|
}
|
|
|
|
license, err := ValidateLicense(licenseKey)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("validate license: %w", err)
|
|
}
|
|
|
|
// JWT validated successfully — now safe to tear down any existing activation.
|
|
s.StopGrantRefresh()
|
|
s.StopRevocationPoll()
|
|
|
|
s.mu.Lock()
|
|
s.license = cloneLicense(license)
|
|
source := NewTokenSource(&s.license.Claims)
|
|
s.evaluator = NewEvaluator(source)
|
|
// Clear any activation state — this is now a legacy JWT license.
|
|
s.activationState = nil
|
|
cb := s.onLicenseChange
|
|
activationCB := s.onActivationStateChange
|
|
snapshot := cloneLicense(s.license)
|
|
persistence := s.persistence
|
|
s.mu.Unlock()
|
|
|
|
// Remove persisted activation state if present.
|
|
if persistence != nil {
|
|
_ = persistence.ClearActivationState()
|
|
}
|
|
|
|
if cb != nil {
|
|
cb(snapshot)
|
|
}
|
|
if activationCB != nil {
|
|
activationCB(nil)
|
|
}
|
|
|
|
// Keep legacy mutability in explicit dev-mode to avoid breaking existing
|
|
// test fixtures that patch claims after activation. In production mode,
|
|
// callers receive an immutable snapshot to prevent state tampering.
|
|
if isLicenseValidationDevMode() {
|
|
return s.license, nil
|
|
}
|
|
return snapshot, nil
|
|
}
|
|
|
|
func looksLikeLegacyJWTLicense(licenseKey string) bool {
|
|
parts := strings.Split(strings.TrimSpace(licenseKey), ".")
|
|
return len(parts) == 3 && parts[0] != "" && parts[1] != "" && parts[2] != ""
|
|
}
|
|
|
|
// IsLicenseValidationDevMode reports whether legacy JWT validation is enabled
|
|
// for development and test fixtures.
|
|
func IsLicenseValidationDevMode() bool {
|
|
return isLicenseValidationDevMode()
|
|
}
|
|
|
|
// ActivateWithKey activates a license using an activation key from the license server.
|
|
// It creates an installation, receives a relay grant, parses it, and sets the
|
|
// resulting license as active. The activation state is persisted for background refresh.
|
|
func (s *Service) ActivateWithKey(activationKey string) (*License, error) {
|
|
s.mu.RLock()
|
|
client := s.serverClient
|
|
persistence := s.persistence
|
|
clientVersion := s.clientVersion
|
|
runtimeIdentity := NormalizeRuntimeIdentity(s.runtimeIdentity)
|
|
s.mu.RUnlock()
|
|
|
|
if client == nil {
|
|
return nil, fmt.Errorf("activation unavailable: license server client not configured")
|
|
}
|
|
|
|
// Generate a stable fingerprint for this installation.
|
|
fingerprint, err := generateFingerprint()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("generate instance fingerprint: %w", err)
|
|
}
|
|
|
|
hostname, _ := os.Hostname()
|
|
req := ActivateInstallationRequest{
|
|
ActivationKey: activationKey,
|
|
InstanceFingerprint: fingerprint,
|
|
InstanceName: hostname,
|
|
ClientVersion: clientVersion,
|
|
Runtime: CloneRuntimeIdentity(runtimeIdentity),
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel()
|
|
|
|
resp, err := client.Activate(ctx, req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("activation failed: %w", err)
|
|
}
|
|
|
|
return s.applyActivationResponse(resp, fingerprint, client.BaseURL(), persistence, ActivationContinuity{})
|
|
}
|
|
|
|
// ActivateLegacyLicense exchanges a legacy v5 JWT-style license for a v6 activation.
|
|
func (s *Service) ActivateLegacyLicense(legacyLicenseKey string) (*License, error) {
|
|
s.mu.RLock()
|
|
client := s.serverClient
|
|
persistence := s.persistence
|
|
clientVersion := s.clientVersion
|
|
runtimeIdentity := NormalizeRuntimeIdentity(s.runtimeIdentity)
|
|
s.mu.RUnlock()
|
|
|
|
if client == nil {
|
|
return nil, fmt.Errorf("activation unavailable: license server client not configured")
|
|
}
|
|
|
|
fingerprint, err := generateFingerprint()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("generate instance fingerprint: %w", err)
|
|
}
|
|
|
|
hostname, _ := os.Hostname()
|
|
req := ExchangeLegacyLicenseRequest{
|
|
LegacyLicenseKey: legacyLicenseKey,
|
|
InstanceFingerprint: fingerprint,
|
|
InstanceName: hostname,
|
|
ClientVersion: clientVersion,
|
|
Runtime: CloneRuntimeIdentity(runtimeIdentity),
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel()
|
|
|
|
resp, err := client.ExchangeLegacyLicense(ctx, req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("activation failed: %w", err)
|
|
}
|
|
|
|
return s.applyActivationResponse(resp, fingerprint, client.BaseURL(), persistence, ActivationContinuity{
|
|
LegacyMigration: true,
|
|
})
|
|
}
|
|
|
|
func (s *Service) applyActivationResponse(
|
|
resp *ActivateInstallationResponse,
|
|
fingerprint string,
|
|
serverURL string,
|
|
persistence *Persistence,
|
|
continuity ActivationContinuity,
|
|
) (*License, error) {
|
|
// Parse the grant JWT to extract claims.
|
|
gc, err := verifyAndParseGrantJWT(resp.Grant.JWT)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse grant from activation: %w", err)
|
|
}
|
|
|
|
// Build the license from grant claims.
|
|
continuity = normalizeActivationContinuity(continuity)
|
|
lic := grantClaimsToLicenseWithContinuity(gc, resp.Grant.JWT, continuity)
|
|
|
|
// Build activation state for persistence and refresh.
|
|
now := time.Now().Unix()
|
|
state := &ActivationState{
|
|
InstallationID: resp.Installation.InstallationID,
|
|
InstallationToken: resp.Installation.InstallationToken,
|
|
LicenseID: resp.License.LicenseID,
|
|
GrantJWT: resp.Grant.JWT,
|
|
GrantJTI: resp.Grant.JTI,
|
|
GrantExpiresAt: resp.Grant.ParseExpiresAt(),
|
|
InstanceFingerprint: fingerprint,
|
|
LicenseServerURL: serverURL,
|
|
ActivatedAt: now,
|
|
LastRefreshedAt: now,
|
|
Continuity: continuity,
|
|
}
|
|
|
|
s.mu.Lock()
|
|
s.license = cloneLicense(lic)
|
|
source := NewTokenSource(&s.license.Claims)
|
|
s.evaluator = NewEvaluator(source)
|
|
s.activationState = state
|
|
cb := s.onLicenseChange
|
|
activationCB := s.onActivationStateChange
|
|
snapshot := cloneLicense(s.license)
|
|
stateSnapshot := cloneActivationState(state)
|
|
s.mu.Unlock()
|
|
|
|
// Apply refresh hints from the server.
|
|
s.SetRefreshHints(resp.RefreshPolicy)
|
|
|
|
// Persist activation state.
|
|
if persistence != nil {
|
|
if err := persistence.SaveActivationState(state); err != nil {
|
|
// Log but don't fail — the activation succeeded.
|
|
// The grant won't survive restarts without persistence, but that's a degraded mode.
|
|
fmt.Fprintf(os.Stderr, "warning: failed to persist activation state: %v\n", err)
|
|
}
|
|
}
|
|
|
|
if cb != nil {
|
|
cb(snapshot)
|
|
}
|
|
if activationCB != nil {
|
|
activationCB(stateSnapshot)
|
|
}
|
|
|
|
return snapshot, nil
|
|
}
|
|
|
|
// IsActivated returns true if the service has an active activation-key license.
|
|
func (s *Service) IsActivated() bool {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.activationState != nil
|
|
}
|
|
|
|
// GetActivationState returns a copy of the current activation state, or nil.
|
|
func (s *Service) GetActivationState() *ActivationState {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return cloneActivationState(s.activationState)
|
|
}
|
|
|
|
// SetLicenseServerClient sets the HTTP client for license server communication.
|
|
func (s *Service) SetLicenseServerClient(client *LicenseServerClient) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.serverClient = client
|
|
}
|
|
|
|
// SetClientVersion records the canonical runtime version for authenticated
|
|
// installation/version attribution on activation and grant refresh.
|
|
func (s *Service) SetClientVersion(version string) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.clientVersion = strings.TrimSpace(version)
|
|
}
|
|
|
|
// SetRuntimeIdentity records the canonical runtime build for authenticated
|
|
// installation attribution on activation and grant refresh.
|
|
func (s *Service) SetRuntimeIdentity(identity RuntimeIdentity) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.runtimeIdentity = NormalizeRuntimeIdentity(identity)
|
|
}
|
|
|
|
// SetPersistence sets the persistence reference for activation state save/load.
|
|
func (s *Service) SetPersistence(p *Persistence) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.persistence = p
|
|
}
|
|
|
|
// RestoreActivation restores the license from persisted activation state.
|
|
// Called during startup to resume an activation without re-activating.
|
|
// Even if the grant is expired, the refresh loop will renew it.
|
|
func (s *Service) RestoreActivation(state *ActivationState) error {
|
|
if state == nil {
|
|
return nil
|
|
}
|
|
|
|
gc, err := verifyAndParseGrantJWT(state.GrantJWT)
|
|
if err != nil {
|
|
return fmt.Errorf("parse persisted grant: %w", err)
|
|
}
|
|
|
|
stateCopy := *state
|
|
stateCopy.Continuity = normalizeActivationContinuity(stateCopy.Continuity)
|
|
|
|
lic := grantClaimsToLicenseWithContinuity(gc, stateCopy.GrantJWT, stateCopy.Continuity)
|
|
|
|
s.mu.Lock()
|
|
s.license = cloneLicense(lic)
|
|
source := NewTokenSource(&s.license.Claims)
|
|
s.evaluator = NewEvaluator(source)
|
|
s.activationState = &stateCopy
|
|
cb := s.onLicenseChange
|
|
activationCB := s.onActivationStateChange
|
|
snapshot := cloneLicense(s.license)
|
|
stateSnapshot := cloneActivationState(&stateCopy)
|
|
s.mu.Unlock()
|
|
|
|
if cb != nil {
|
|
cb(snapshot)
|
|
}
|
|
if activationCB != nil {
|
|
activationCB(stateSnapshot)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Clear removes the current license.
|
|
// If an activation-key license is present, it also stops the refresh loop and clears the state.
|
|
func (s *Service) Clear() {
|
|
// Stop background loops first (outside the lock to avoid deadlock).
|
|
s.StopGrantRefresh()
|
|
s.StopRevocationPoll()
|
|
|
|
s.mu.Lock()
|
|
s.license = nil
|
|
s.evaluator = nil
|
|
persistence := s.persistence
|
|
hadActivation := s.activationState != nil
|
|
s.activationState = nil
|
|
cb := s.onLicenseChange
|
|
activationCB := s.onActivationStateChange
|
|
s.mu.Unlock()
|
|
|
|
// Clear persisted activation state if it existed.
|
|
if hadActivation && persistence != nil {
|
|
if err := persistence.ClearActivationState(); err != nil {
|
|
fmt.Fprintf(os.Stderr, "warning: failed to clear activation state: %v\n", err)
|
|
}
|
|
}
|
|
|
|
if cb != nil {
|
|
cb(nil)
|
|
}
|
|
if activationCB != nil {
|
|
activationCB(nil)
|
|
}
|
|
}
|
|
|
|
// Current returns the current license, or nil if none.
|
|
func (s *Service) Current() *License {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return cloneLicense(s.license)
|
|
}
|
|
|
|
// SetCurrentForTesting injects a license pointer directly into service state.
|
|
// This is intended for deterministic unit tests.
|
|
func (s *Service) SetCurrentForTesting(license *License) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.license = license
|
|
}
|
|
|
|
// CurrentUnsafeForTesting returns the internal license pointer for in-place mutation.
|
|
// This is intended for deterministic unit tests.
|
|
func (s *Service) CurrentUnsafeForTesting() *License {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.license
|
|
}
|
|
|
|
// IsValid returns true if a valid, non-expired license is active.
|
|
func (s *Service) IsValid() bool {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if s.license == nil {
|
|
return false
|
|
}
|
|
|
|
state := s.currentJWTSubscriptionStateLocked(time.Now())
|
|
return subscriptionStateHasPaidFeatures(state)
|
|
}
|
|
|
|
// HasFeature checks if the current license grants a feature.
|
|
func (s *Service) HasFeature(feature string) bool {
|
|
// In demo mode or dev mode, grant all Pro features
|
|
if isDemoMode() || isDevMode() {
|
|
if devModeFeatureEnabled(feature) {
|
|
return true
|
|
}
|
|
// Features excluded from the implicit dev grant (white_label,
|
|
// multi_user, unlimited, env-gated multi_tenant) fall through to
|
|
// real entitlement evaluation, so an explicitly activated license
|
|
// behaves the same in dev builds as in release builds.
|
|
}
|
|
|
|
s.mu.Lock() // Need write lock since we may update grace period
|
|
defer s.mu.Unlock()
|
|
|
|
if s.license == nil {
|
|
// Hosted path: evaluator drives entitlements when no JWT is present.
|
|
if s.evaluator != nil {
|
|
// Always include free-tier features, regardless of evaluator state.
|
|
if TierHasFeature(TierFree, feature) {
|
|
return true
|
|
}
|
|
|
|
// Only grant paid capabilities when the subscription state permits it.
|
|
if !subscriptionStateHasPaidFeatures(s.evaluator.SubscriptionState()) {
|
|
return false
|
|
}
|
|
return s.evaluator.HasCapability(feature)
|
|
}
|
|
// No license activated — still grant free tier features
|
|
return TierHasFeature(TierFree, feature)
|
|
}
|
|
|
|
// JWT takes precedence whenever a license is present (including hybrid mode).
|
|
if !subscriptionStateHasPaidFeatures(s.currentJWTSubscriptionStateLocked(time.Now())) {
|
|
return TierHasFeature(TierFree, feature)
|
|
}
|
|
|
|
return s.license.HasFeature(feature)
|
|
}
|
|
|
|
// isDemoMode, isDevMode, and isLicenseValidationDevMode are defined in
|
|
// dev_mode_dev.go (default builds) and dev_mode_release.go (release builds).
|
|
// In release builds these always return false, preventing env-var bypass of
|
|
// feature gating and license signature validation.
|
|
|
|
const (
|
|
maxLicenseKeyLength = 16 << 10 // 16 KiB
|
|
maxLicenseSegmentLength = 8 << 10 // 8 KiB
|
|
maxLicensePayloadSize = 8 << 10 // 8 KiB decoded JSON
|
|
)
|
|
|
|
// GetLicenseState returns the current license state and the license itself
|
|
func (s *Service) GetLicenseState() (LicenseState, *License) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if s.license == nil {
|
|
if s.evaluator != nil {
|
|
switch s.evaluator.SubscriptionState() {
|
|
case SubStateActive, SubStateTrial:
|
|
return LicenseStateActive, nil
|
|
case SubStateGrace:
|
|
return LicenseStateGracePeriod, nil
|
|
default:
|
|
// Suspended/canceled/expired (or unknown) are treated as non-entitled.
|
|
return LicenseStateExpired, nil
|
|
}
|
|
}
|
|
return LicenseStateNone, nil
|
|
}
|
|
|
|
state := s.currentJWTSubscriptionStateLocked(time.Now())
|
|
switch state {
|
|
case SubStateActive, SubStateTrial:
|
|
return LicenseStateActive, cloneLicense(s.license)
|
|
case SubStateGrace:
|
|
return LicenseStateGracePeriod, cloneLicense(s.license)
|
|
default:
|
|
return LicenseStateExpired, cloneLicense(s.license)
|
|
}
|
|
}
|
|
|
|
// GetLicenseStateString returns the current license state as string and whether features are available
|
|
// This implements the LicenseChecker interface for the AI service
|
|
func (s *Service) GetLicenseStateString() (string, bool) {
|
|
state, _ := s.GetLicenseState()
|
|
hasFeatures := state == LicenseStateActive || state == LicenseStateGracePeriod
|
|
return string(state), hasFeatures
|
|
}
|
|
|
|
// RequireFeature returns an error if the feature is not available.
|
|
// This is the primary method for feature gating.
|
|
func (s *Service) RequireFeature(feature string) error {
|
|
if !s.HasFeature(feature) {
|
|
return fmt.Errorf("%w: %s requires Pulse %s or above", ErrFeatureNotIncluded, GetFeatureDisplayName(feature), GetFeatureMinTierName(feature))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SubscriptionState returns the current normalized subscription state.
|
|
func (s *Service) SubscriptionState() string {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if s.stateMachineConfigured && s.license != nil && s.license.Claims.SubState != "" {
|
|
return string(s.license.Claims.SubState)
|
|
}
|
|
|
|
if s.license == nil && s.evaluator != nil {
|
|
return string(s.evaluator.SubscriptionState())
|
|
}
|
|
if s.license == nil {
|
|
return string(SubStateExpired)
|
|
}
|
|
|
|
return string(s.currentJWTSubscriptionStateLocked(time.Now()))
|
|
}
|
|
|
|
// Status returns a summary of the current license status.
|
|
func (s *Service) Status() *LicenseStatus {
|
|
s.mu.Lock() // Need write lock since we may update grace period
|
|
defer s.mu.Unlock()
|
|
|
|
status := &LicenseStatus{
|
|
Valid: false,
|
|
Tier: TierFree,
|
|
Features: append([]string(nil), TierFeatures[TierFree]...),
|
|
}
|
|
|
|
if s.license == nil {
|
|
// Hosted path: evaluator drives status when no JWT is present.
|
|
if s.evaluator != nil {
|
|
status.PlanVersion = s.evaluator.PlanVersion()
|
|
subState := s.evaluator.SubscriptionState()
|
|
switch subState {
|
|
case SubStateActive, SubStateTrial, SubStateGrace:
|
|
status.Tier = TierPro // hosted billing-backed tenants are effectively "pro" while entitled
|
|
status.Valid = true
|
|
if subState == SubStateGrace {
|
|
status.InGracePeriod = true
|
|
}
|
|
if subState == SubStateTrial {
|
|
if trialEndsAt := s.evaluator.TrialEndsAt(); trialEndsAt != nil {
|
|
expiresAt := time.Unix(*trialEndsAt, 0).Format(time.RFC3339)
|
|
status.ExpiresAt = &expiresAt
|
|
status.DaysRemaining = remainingDaysCeil(*trialEndsAt, time.Now().Unix())
|
|
}
|
|
}
|
|
status.Features = unionFeatures(TierFeatures[TierFree], evaluatorFeatures(s.evaluator))
|
|
|
|
if maxGuests, ok := s.evaluator.GetLimit("max_guests"); ok {
|
|
status.MaxGuests = safeIntFromInt64(maxGuests)
|
|
}
|
|
default:
|
|
status.Tier = TierFree
|
|
status.Valid = false
|
|
// Keep effective capabilities free-tier only when subscription is not entitled.
|
|
status.Features = append([]string(nil), TierFeatures[TierFree]...)
|
|
status.MaxGuests = 0
|
|
}
|
|
} else {
|
|
// No license, no evaluator: Community remains active for core monitoring.
|
|
}
|
|
return status
|
|
}
|
|
|
|
status.Email = s.license.Claims.Email
|
|
status.Tier = s.license.Claims.Tier
|
|
status.PlanVersion = s.license.Claims.EntitlementPlanVersion()
|
|
status.IsLifetime = s.license.IsLifetime()
|
|
status.DaysRemaining = s.license.DaysRemaining()
|
|
status.Features = s.license.AllFeatures()
|
|
|
|
if maxGuests, ok := s.license.Claims.EffectiveLimits()["max_guests"]; ok {
|
|
status.MaxGuests = safeIntFromInt64(maxGuests)
|
|
}
|
|
|
|
if s.license.ExpiresAt() != nil {
|
|
exp := s.license.ExpiresAt().Format(time.RFC3339)
|
|
status.ExpiresAt = &exp
|
|
}
|
|
|
|
switch subState := s.currentJWTSubscriptionStateLocked(time.Now()); subState {
|
|
case SubStateActive, SubStateTrial:
|
|
status.Valid = true
|
|
case SubStateGrace:
|
|
status.Valid = true
|
|
status.InGracePeriod = true
|
|
if s.license.GracePeriodEnd != nil {
|
|
graceEnd := s.license.GracePeriodEnd.Format(time.RFC3339)
|
|
status.GracePeriodEnd = &graceEnd
|
|
}
|
|
default:
|
|
status.Valid = false
|
|
status.Features = append([]string(nil), TierFeatures[TierFree]...)
|
|
status.MaxGuests = 0
|
|
}
|
|
|
|
return status
|
|
}
|
|
|
|
func (s *Service) currentJWTSubscriptionStateLocked(now time.Time) SubscriptionState {
|
|
if s.license == nil {
|
|
return SubStateExpired
|
|
}
|
|
|
|
// Explicit revocation states in JWT claims should always revoke paid access.
|
|
switch s.license.Claims.SubState {
|
|
case SubStateSuspended, SubStateCanceled, SubStateExpired:
|
|
return s.license.Claims.SubState
|
|
}
|
|
|
|
// Expiration/grace handling applies regardless of claim substate.
|
|
if s.license.IsExpired() {
|
|
s.ensureGracePeriodEnd()
|
|
if s.license.GracePeriodEnd != nil && now.Before(*s.license.GracePeriodEnd) {
|
|
return SubStateGrace
|
|
}
|
|
return SubStateExpired
|
|
}
|
|
|
|
if s.license.Claims.SubState != "" {
|
|
return s.license.Claims.SubState
|
|
}
|
|
|
|
return SubStateActive
|
|
}
|
|
|
|
func subscriptionStateHasPaidFeatures(state SubscriptionState) bool {
|
|
switch state {
|
|
case SubStateActive, SubStateTrial, SubStateGrace:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func evaluatorFeatures(eval *Evaluator) []string {
|
|
if eval == nil {
|
|
return []string{}
|
|
}
|
|
|
|
// Derive a stable, known capability list from the evaluator by enumerating
|
|
// all feature keys currently used by tier-based gating.
|
|
caps := make([]string, 0, len(allKnownFeatures()))
|
|
for _, feature := range allKnownFeatures() {
|
|
if eval.HasCapability(feature) {
|
|
caps = append(caps, feature)
|
|
}
|
|
}
|
|
sort.Strings(caps)
|
|
return caps
|
|
}
|
|
|
|
func allKnownFeatures() []string {
|
|
known := make(map[string]struct{}, 32)
|
|
for _, features := range TierFeatures {
|
|
for _, feature := range features {
|
|
known[feature] = struct{}{}
|
|
}
|
|
}
|
|
|
|
out := make([]string, 0, len(known))
|
|
for feature := range known {
|
|
out = append(out, feature)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
func unionFeatures(a, b []string) []string {
|
|
set := make(map[string]struct{}, len(a)+len(b))
|
|
for _, v := range a {
|
|
set[v] = struct{}{}
|
|
}
|
|
for _, v := range b {
|
|
set[v] = struct{}{}
|
|
}
|
|
|
|
out := make([]string, 0, len(set))
|
|
for v := range set {
|
|
out = append(out, v)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
func safeIntFromInt64(v int64) int {
|
|
// Avoid overflow on 32-bit platforms; clamp to max int.
|
|
maxInt := int64(^uint(0) >> 1)
|
|
if v > maxInt {
|
|
return int(maxInt)
|
|
}
|
|
if v < 0 {
|
|
return 0
|
|
}
|
|
return int(v)
|
|
}
|
|
|
|
func remainingDaysCeil(expiresAtUnix, nowUnix int64) int {
|
|
deltaSeconds := expiresAtUnix - nowUnix
|
|
if deltaSeconds <= 0 {
|
|
return 0
|
|
}
|
|
return int(math.Ceil(float64(deltaSeconds) / 86400.0))
|
|
}
|
|
|
|
func cloneLicense(in *License) *License {
|
|
if in == nil {
|
|
return nil
|
|
}
|
|
|
|
out := *in
|
|
out.Claims = cloneClaims(in.Claims)
|
|
if in.GracePeriodEnd != nil {
|
|
graceEnd := *in.GracePeriodEnd
|
|
out.GracePeriodEnd = &graceEnd
|
|
}
|
|
return &out
|
|
}
|
|
|
|
func cloneActivationState(in *ActivationState) *ActivationState {
|
|
if in == nil {
|
|
return nil
|
|
}
|
|
|
|
out := *in
|
|
out.Continuity = normalizeActivationContinuity(in.Continuity)
|
|
return &out
|
|
}
|
|
|
|
func cloneClaims(in Claims) Claims {
|
|
out := in
|
|
if in.Features != nil {
|
|
out.Features = append([]string(nil), in.Features...)
|
|
}
|
|
if in.Capabilities != nil {
|
|
out.Capabilities = append([]string(nil), in.Capabilities...)
|
|
}
|
|
if in.Limits != nil {
|
|
out.Limits = make(map[string]int64, len(in.Limits))
|
|
for key, value := range in.Limits {
|
|
out.Limits[key] = value
|
|
}
|
|
}
|
|
if in.MetersEnabled != nil {
|
|
out.MetersEnabled = append([]string(nil), in.MetersEnabled...)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ValidateLicense validates a license key and returns the license if valid.
|
|
func ValidateLicense(licenseKey string) (*License, error) {
|
|
// Trim whitespace
|
|
licenseKey = strings.TrimSpace(licenseKey)
|
|
if licenseKey == "" {
|
|
return nil, ErrInvalidLicense
|
|
}
|
|
if len(licenseKey) > maxLicenseKeyLength {
|
|
return nil, fmt.Errorf("%w: license key exceeds size limit", ErrMalformedLicense)
|
|
}
|
|
|
|
// Parse JWT (base64url.base64url.base64url)
|
|
parts := strings.Split(licenseKey, ".")
|
|
if len(parts) != 3 {
|
|
return nil, ErrMalformedLicense
|
|
}
|
|
for _, part := range parts {
|
|
if part == "" {
|
|
return nil, fmt.Errorf("%w: empty jwt segment", ErrMalformedLicense)
|
|
}
|
|
if len(part) > maxLicenseSegmentLength {
|
|
return nil, fmt.Errorf("%w: jwt segment exceeds size limit", ErrMalformedLicense)
|
|
}
|
|
}
|
|
|
|
// Decode header (not used currently, but validate it exists)
|
|
_, err := base64.RawURLEncoding.DecodeString(parts[0])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: invalid header encoding", ErrMalformedLicense)
|
|
}
|
|
|
|
// Decode payload
|
|
payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[1])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: invalid payload encoding", ErrMalformedLicense)
|
|
}
|
|
if len(payloadBytes) > maxLicensePayloadSize {
|
|
return nil, fmt.Errorf("%w: payload exceeds size limit", ErrMalformedLicense)
|
|
}
|
|
|
|
// Decode signature
|
|
signature, err := base64.RawURLEncoding.DecodeString(parts[2])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: invalid signature encoding", ErrMalformedLicense)
|
|
}
|
|
|
|
// Verify signature
|
|
// In production, public key MUST be set. In dev mode, we skip signature validation.
|
|
devMode := isLicenseValidationDevMode()
|
|
signedData := []byte(parts[0] + "." + parts[1])
|
|
key := currentPublicKey()
|
|
|
|
if len(key) > 0 {
|
|
if !ed25519.Verify(key, signedData, signature) {
|
|
return nil, ErrSignatureInvalid
|
|
}
|
|
} else if !devMode {
|
|
// No public key and not in dev mode - fail validation
|
|
return nil, fmt.Errorf("%w: signature verification required", ErrNoPublicKey)
|
|
}
|
|
// If devMode and no public key, we skip signature verification (for testing only)
|
|
|
|
// Parse claims
|
|
var claims Claims
|
|
if err := json.Unmarshal(payloadBytes, &claims); err != nil {
|
|
return nil, fmt.Errorf("%w: invalid claims JSON", ErrMalformedLicense)
|
|
}
|
|
|
|
// Validate required fields
|
|
if claims.LicenseID == "" {
|
|
return nil, fmt.Errorf("%w: missing license ID", ErrMalformedLicense)
|
|
}
|
|
if claims.Email == "" {
|
|
return nil, fmt.Errorf("%w: missing email", ErrMalformedLicense)
|
|
}
|
|
if claims.Tier == "" {
|
|
return nil, fmt.Errorf("%w: missing tier", ErrMalformedLicense)
|
|
}
|
|
|
|
license := &License{
|
|
Raw: licenseKey,
|
|
Claims: claims,
|
|
ValidatedAt: time.Now(),
|
|
}
|
|
|
|
// Check expiration with grace period support
|
|
if license.IsExpired() {
|
|
// Calculate how long ago it expired
|
|
expirationTime := time.Unix(claims.ExpiresAt, 0)
|
|
gracePeriodEnd := expirationTime.Add(DefaultGracePeriod)
|
|
|
|
if time.Now().Before(gracePeriodEnd) {
|
|
// Within grace period - allow activation but mark as in grace period
|
|
license.GracePeriodEnd = &gracePeriodEnd
|
|
// License is still valid during grace period
|
|
} else {
|
|
// Past grace period - reject
|
|
return nil, fmt.Errorf("%w: expired on %s (grace period ended %s)",
|
|
ErrExpiredLicense,
|
|
expirationTime.Format("2006-01-02"),
|
|
gracePeriodEnd.Format("2006-01-02"))
|
|
}
|
|
}
|
|
|
|
return license, nil
|
|
}
|
|
|
|
// generateFingerprint creates a random UUID v4 for identifying this installation.
|
|
func generateFingerprint() (string, error) {
|
|
var uuid [16]byte
|
|
if _, err := rand.Read(uuid[:]); err != nil {
|
|
return "", err
|
|
}
|
|
// Set version 4 bits.
|
|
uuid[6] = (uuid[6] & 0x0f) | 0x40
|
|
// Set variant bits.
|
|
uuid[8] = (uuid[8] & 0x3f) | 0x80
|
|
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
|
|
uuid[0:4], uuid[4:6], uuid[6:8], uuid[8:10], uuid[10:16]), nil
|
|
}
|