Pulse/pkg/licensing/entitlement_payload.go
rcourtman f7aa9ee922 Remove unreachable exported functions from pkg/
Dead-code audit batch 1: five functions with zero references from main,
tests, or pulse-enterprise (deadcode -test, grep-verified per symbol,
enterprise compile-checked). Also renames the tlsutil test that
exercised the deleted compat alias's target to name the Unverified
function it actually calls.

Skipped from the audit list after re-verification: IsNilAlertPayload
(live pulse-enterprise consumer in internal/aialertanalysis).
2026-07-10 00:27:35 +01:00

532 lines
18 KiB
Go

package licensing
import (
"math"
"strings"
"time"
)
// EntitlementPayload is the normalized entitlement response for frontend consumption.
// Frontend should use this instead of inferring capabilities from tier names.
type EntitlementPayload struct {
// Capabilities lists all granted capability keys.
Capabilities []string `json:"capabilities"`
// Limits lists quantitative limits with current usage.
Limits []LimitStatus `json:"limits"`
// SubscriptionState is the current subscription lifecycle state.
SubscriptionState string `json:"subscription_state"`
// UpgradeReasons provides user-actionable upgrade prompts.
UpgradeReasons []UpgradeReason `json:"upgrade_reasons"`
// PlanVersion preserves grandfathered terms.
PlanVersion string `json:"plan_version,omitempty"`
// Tier is the marketing tier name (for display only, never gate on this).
Tier string `json:"tier"`
// TrialExpiresAt is the trial expiration Unix timestamp when in trial state.
TrialExpiresAt *int64 `json:"trial_expires_at,omitempty"`
// TrialDaysRemaining is the number of whole or partial days remaining in trial.
TrialDaysRemaining *int `json:"trial_days_remaining,omitempty"`
// HostedMode indicates that this server is running in Pulse hosted mode.
// It is used by the frontend to gate hosted-control-plane-only UI.
HostedMode bool `json:"hosted_mode"`
// Valid mirrors the effective license validity for display surfaces.
Valid bool `json:"valid"`
// LicensedEmail is the activated license email when available.
LicensedEmail string `json:"licensed_email,omitempty"`
// ExpiresAt is the RFC3339 expiration timestamp when available.
ExpiresAt *string `json:"expires_at,omitempty"`
// IsLifetime indicates a lifetime entitlement with no expiration.
IsLifetime bool `json:"is_lifetime"`
// DaysRemaining is the number of days left until expiration.
DaysRemaining int `json:"days_remaining"`
// InGracePeriod indicates whether the entitlement is currently in grace.
InGracePeriod bool `json:"in_grace_period,omitempty"`
// GracePeriodEnd is the RFC3339 grace period end timestamp when available.
GracePeriodEnd *string `json:"grace_period_end,omitempty"`
// TrialEligible is retained for compatibility with retired self-hosted trial clients.
// New self-hosted payloads leave it false; active trial state is exposed through
// SubscriptionState, TrialExpiresAt, and TrialDaysRemaining.
TrialEligible bool `json:"trial_eligible"`
// TrialEligibilityReason is retained for compatibility with retired self-hosted trial clients.
TrialEligibilityReason string `json:"trial_eligibility_reason,omitempty"`
// MaxHistoryDays is the maximum metrics history retention in days for the current tier.
MaxHistoryDays int `json:"max_history_days"`
// OverflowDaysRemaining is set when the onboarding overflow (+1 host) is active.
// Indicates the number of days remaining in the 14-day overflow window.
OverflowDaysRemaining *int `json:"overflow_days_remaining,omitempty"`
// LegacyConnections is retained for response compatibility and informational
// estate review only.
LegacyConnections LegacyConnectionCounts `json:"legacy_connections"`
// HasMigrationGap is retained for response compatibility. It no longer
// describes a commercial volume limit.
HasMigrationGap bool `json:"has_migration_gap"`
// CommercialMigration reports unresolved paid-license migration work entering
// from v5-era commercial state.
CommercialMigration *CommercialMigrationStatus `json:"commercial_migration,omitempty"`
// Runtime identifies the binary/channel serving this entitlement payload.
Runtime *RuntimeIdentity `json:"runtime,omitempty"`
}
// CommercialPosturePayload is the canonical non-billing commercial contract
// for upgrade messaging and paid migration copy.
// It intentionally excludes billing identity, grandfathered plan terms, and
// other full-entitlement details that belong only to billing surfaces.
type CommercialPosturePayload struct {
// SubscriptionState is the current subscription lifecycle state.
SubscriptionState string `json:"subscription_state"`
// UpgradeReasons provides user-actionable upgrade prompts.
UpgradeReasons []UpgradeReason `json:"upgrade_reasons"`
// Tier is the marketing tier name (for display only, never gate on this).
Tier string `json:"tier"`
// TrialExpiresAt is the trial expiration Unix timestamp when in trial state.
TrialExpiresAt *int64 `json:"trial_expires_at,omitempty"`
// TrialDaysRemaining is the number of whole or partial days remaining in trial.
TrialDaysRemaining *int `json:"trial_days_remaining,omitempty"`
// TrialEligible is retained for compatibility with retired self-hosted trial clients.
TrialEligible bool `json:"trial_eligible"`
// TrialEligibilityReason is retained for compatibility with retired self-hosted trial clients.
TrialEligibilityReason string `json:"trial_eligibility_reason,omitempty"`
// OverflowDaysRemaining is set when the onboarding overflow (+1 host) is active.
OverflowDaysRemaining *int `json:"overflow_days_remaining,omitempty"`
// LegacyConnections is retained for response compatibility and informational
// estate review only.
LegacyConnections LegacyConnectionCounts `json:"legacy_connections"`
// HasMigrationGap is retained for response compatibility. It no longer
// describes a commercial volume limit.
HasMigrationGap bool `json:"has_migration_gap"`
// CommercialMigration reports unresolved paid-license migration work entering
// from v5-era commercial state.
CommercialMigration *CommercialMigrationStatus `json:"commercial_migration,omitempty"`
}
// RuntimeCapabilitiesPayload is the canonical non-commercial license contract
// for feature gating and runtime retention/limit decisions.
type RuntimeCapabilitiesPayload struct {
// Capabilities lists all granted capability keys.
Capabilities []string `json:"capabilities"`
// Limits lists quantitative limits with current usage.
Limits []LimitStatus `json:"limits"`
// HostedMode indicates that this server is running in Pulse hosted mode.
HostedMode bool `json:"hosted_mode"`
// MaxHistoryDays is the maximum metrics history retention in days for the current tier.
MaxHistoryDays int `json:"max_history_days"`
// Runtime identifies the binary/channel serving the runtime capability contract.
Runtime *RuntimeIdentity `json:"runtime,omitempty"`
// BlockedCapabilities lists licensed capabilities that this binary cannot serve.
BlockedCapabilities []RuntimeCapabilityBlock `json:"blocked_capabilities,omitempty"`
}
const (
RuntimeBuildCommunity = "community"
RuntimeBuildPro = "pro"
PulseProDownloadURL = "https://pulserelay.pro/download.html"
)
type RuntimeIdentity struct {
Build string `json:"build"`
Label string `json:"label"`
DownloadURL string `json:"download_url,omitempty"`
}
type RuntimeCapabilityBlock struct {
Key string `json:"key"`
Reason string `json:"reason"`
ActionURL string `json:"action_url,omitempty"`
}
// LimitStatus represents a quantitative limit with current usage state.
type LimitStatus struct {
// Key is the limit identifier (e.g., "max_guests").
Key string `json:"key"`
// Limit is the maximum allowed value (0 = unlimited).
Limit int64 `json:"limit"`
// Current is the observed current usage.
Current int64 `json:"current"`
// State describes the over-limit UX state.
// Values: "ok", "warning", "enforced"
State string `json:"state"`
}
// UpgradeReason provides context for why a user should upgrade.
type UpgradeReason struct {
// Key is the capability or limit this reason relates to.
Key string `json:"key"`
// Reason is a user-facing description of why upgrading helps.
Reason string `json:"reason"`
// ActionURL is where the user can go to upgrade.
ActionURL string `json:"action_url,omitempty"`
}
type LegacyConnectionCounts struct {
ProxmoxNodes int64 `json:"proxmox_nodes"`
DockerHosts int64 `json:"docker_hosts"`
KubernetesClusters int64 `json:"kubernetes_clusters"`
}
func cloneCapabilityKeys(values []string) []string {
if len(values) == 0 {
return []string{}
}
return append([]string(nil), values...)
}
func cloneLimitStatuses(values []LimitStatus) []LimitStatus {
if len(values) == 0 {
return []LimitStatus{}
}
return append([]LimitStatus(nil), values...)
}
func CommunityRuntimeIdentity() RuntimeIdentity {
return RuntimeIdentity{
Build: RuntimeBuildCommunity,
Label: "Pulse Community runtime",
}
}
func ProRuntimeIdentity() RuntimeIdentity {
return RuntimeIdentity{
Build: RuntimeBuildPro,
Label: "Pulse Pro runtime",
DownloadURL: PulseProDownloadURL,
}
}
func NormalizeRuntimeIdentity(identity RuntimeIdentity) RuntimeIdentity {
build := strings.ToLower(strings.TrimSpace(identity.Build))
switch build {
case RuntimeBuildPro:
normalized := ProRuntimeIdentity()
if label := strings.TrimSpace(identity.Label); label != "" {
normalized.Label = label
}
if downloadURL := strings.TrimSpace(identity.DownloadURL); downloadURL != "" {
normalized.DownloadURL = downloadURL
}
return normalized
default:
normalized := CommunityRuntimeIdentity()
if label := strings.TrimSpace(identity.Label); label != "" {
normalized.Label = label
}
return normalized
}
}
func IsProRuntime(identity RuntimeIdentity) bool {
return NormalizeRuntimeIdentity(identity).Build == RuntimeBuildPro
}
func CloneRuntimeIdentity(identity RuntimeIdentity) *RuntimeIdentity {
normalized := NormalizeRuntimeIdentity(identity)
return &normalized
}
func FilterCapabilitiesForRuntimeIdentity(
capabilities []string,
identity RuntimeIdentity,
) ([]string, []RuntimeCapabilityBlock) {
cloned := cloneCapabilityKeys(capabilities)
if IsProRuntime(identity) {
return cloned, nil
}
privateRuntimeCapabilities := map[string]struct{}{
FeatureAIAlerts: {},
FeatureAIAutoFix: {},
FeatureKubernetesAI: {},
FeatureAgentProfiles: {},
FeatureRBAC: {},
FeatureAuditLogging: {},
}
filtered := make([]string, 0, len(cloned))
blocked := make([]RuntimeCapabilityBlock, 0)
for _, capability := range cloned {
normalized := strings.TrimSpace(capability)
if _, requiresPrivateRuntime := privateRuntimeCapabilities[normalized]; requiresPrivateRuntime {
blocked = append(blocked, RuntimeCapabilityBlock{
Key: normalized,
Reason: "paid_runtime_required",
ActionURL: PulseProDownloadURL,
})
continue
}
filtered = append(filtered, capability)
}
if blocked == nil {
blocked = []RuntimeCapabilityBlock{}
}
return filtered, blocked
}
func (c LegacyConnectionCounts) Total() int64 {
return c.ProxmoxNodes + c.DockerHosts + c.KubernetesClusters
}
type EntitlementUsageSnapshot struct {
MonitoredSystems int64
MonitoredSystemsAvailable bool
MonitoredSystemsUnavailableReason string
// Nodes is retained only as a deprecated compatibility field. V6 monitored-
// system billing must be backed by an explicit canonical availability signal.
Nodes int64
Guests int64
LegacyConnections LegacyConnectionCounts
}
// BuildEntitlementPayload constructs the normalized payload from LicenseStatus.
func BuildEntitlementPayload(status *LicenseStatus, subscriptionState string) EntitlementPayload {
return BuildEntitlementPayloadWithUsage(status, subscriptionState, EntitlementUsageSnapshot{}, nil)
}
// BuildRuntimeCapabilitiesPayload constructs the canonical non-commercial
// runtime capability payload from LicenseStatus.
func BuildRuntimeCapabilitiesPayload(
status *LicenseStatus,
subscriptionState string,
) RuntimeCapabilitiesPayload {
return BuildRuntimeCapabilitiesPayloadWithUsage(
status,
subscriptionState,
EntitlementUsageSnapshot{},
)
}
// BuildRuntimeCapabilitiesPayloadWithUsage constructs the canonical
// non-commercial runtime capability payload from LicenseStatus and observed usage.
func BuildRuntimeCapabilitiesPayloadWithUsage(
status *LicenseStatus,
subscriptionState string,
usage EntitlementUsageSnapshot,
) RuntimeCapabilitiesPayload {
entitlementPayload := BuildEntitlementPayloadWithUsage(status, subscriptionState, usage, nil)
return RuntimeCapabilitiesPayload{
Capabilities: cloneCapabilityKeys(entitlementPayload.Capabilities),
Limits: cloneLimitStatuses(entitlementPayload.Limits),
HostedMode: entitlementPayload.HostedMode,
MaxHistoryDays: entitlementPayload.MaxHistoryDays,
}
}
// BuildCommercialPosturePayloadWithUsage constructs the canonical non-billing
// commercial posture payload from LicenseStatus and observed usage.
func BuildCommercialPosturePayloadWithUsage(
status *LicenseStatus,
subscriptionState string,
usage EntitlementUsageSnapshot,
trialEndsAtUnix *int64,
) CommercialPosturePayload {
return CommercialPosturePayloadFromEntitlementPayload(
BuildEntitlementPayloadWithUsage(status, subscriptionState, usage, trialEndsAtUnix),
)
}
// CommercialPosturePayloadFromEntitlementPayload projects the non-billing
// commercial posture fields out of the full entitlement payload.
func CommercialPosturePayloadFromEntitlementPayload(
payload EntitlementPayload,
) CommercialPosturePayload {
sanitized := CommercialPosturePayload{
SubscriptionState: payload.SubscriptionState,
UpgradeReasons: append([]UpgradeReason(nil), payload.UpgradeReasons...),
Tier: payload.Tier,
TrialExpiresAt: payload.TrialExpiresAt,
TrialDaysRemaining: payload.TrialDaysRemaining,
TrialEligible: payload.TrialEligible,
TrialEligibilityReason: payload.TrialEligibilityReason,
OverflowDaysRemaining: payload.OverflowDaysRemaining,
LegacyConnections: payload.LegacyConnections,
HasMigrationGap: payload.HasMigrationGap,
}
if payload.CommercialMigration != nil {
sanitized.CommercialMigration = CloneCommercialMigrationStatus(payload.CommercialMigration)
}
if sanitized.UpgradeReasons == nil {
sanitized.UpgradeReasons = []UpgradeReason{}
}
return sanitized
}
// BuildEntitlementPayloadWithUsage constructs the normalized payload from LicenseStatus and observed usage.
func BuildEntitlementPayloadWithUsage(
status *LicenseStatus,
subscriptionState string,
usage EntitlementUsageSnapshot,
trialEndsAtUnix *int64,
) EntitlementPayload {
if status == nil {
return EntitlementPayload{
Capabilities: []string{},
Limits: []LimitStatus{},
SubscriptionState: string(SubStateExpired),
UpgradeReasons: []UpgradeReason{},
Tier: string(TierFree),
MaxHistoryDays: TierHistoryDays[TierFree],
}
}
maxHistDays := TierHistoryDays[status.Tier]
if maxHistDays == 0 {
maxHistDays = TierHistoryDays[TierFree]
}
payload := EntitlementPayload{
Capabilities: FilterPublicCapabilities(status.Features),
Limits: []LimitStatus{},
PlanVersion: status.PlanVersion,
Tier: string(status.Tier),
UpgradeReasons: []UpgradeReason{},
Valid: status.Valid,
LicensedEmail: status.Email,
ExpiresAt: status.ExpiresAt,
IsLifetime: status.IsLifetime,
DaysRemaining: status.DaysRemaining,
InGracePeriod: status.InGracePeriod,
GracePeriodEnd: status.GracePeriodEnd,
MaxHistoryDays: maxHistDays,
LegacyConnections: usage.LegacyConnections,
HasMigrationGap: false,
}
if payload.Capabilities == nil {
payload.Capabilities = []string{}
}
// Use provided subscription state when present; otherwise derive from status.
if subscriptionState == "" {
subState := SubStateActive
if !status.Valid {
subState = SubStateExpired
} else if status.InGracePeriod {
subState = SubStateGrace
}
subscriptionState = string(subState)
}
payload.SubscriptionState = string(GetBehavior(SubscriptionState(subscriptionState)).State)
if payload.SubscriptionState == string(SubStateTrial) {
applyTrialWindow(&payload, status, trialEndsAtUnix, time.Now().Unix())
}
// When subscription state doesn't grant paid features, cap history to free tier.
if !subscriptionStateHasPaidFeatures(SubscriptionState(payload.SubscriptionState)) {
payload.MaxHistoryDays = TierHistoryDays[TierFree]
}
// Build hosted-only runtime limits. Self-hosted commercial volume caps are retired.
if status.Tier == TierCloud && status.MaxGuests > 0 {
payload.Limits = append(payload.Limits, LimitStatus{
Key: "max_guests",
Limit: int64(status.MaxGuests),
Current: usage.Guests,
State: LimitState(usage.Guests, int64(status.MaxGuests)),
})
}
reasons := GenerateUpgradeReasons(payload.Capabilities)
payload.UpgradeReasons = make([]UpgradeReason, 0, len(reasons))
for _, reason := range reasons {
payload.UpgradeReasons = append(payload.UpgradeReasons, UpgradeReason{
Key: reason.Feature,
Reason: reason.Reason,
ActionURL: reason.ActionURL,
})
}
return payload
}
func applyTrialWindow(payload *EntitlementPayload, status *LicenseStatus, trialEndsAtUnix *int64, nowUnix int64) {
if payload == nil || status == nil {
return
}
// Prefer billing-state trial timestamps (hosted/self-hosted trial) over license ExpiresAt.
if trialEndsAtUnix != nil {
expiresAtUnix := *trialEndsAtUnix
payload.TrialExpiresAt = &expiresAtUnix
daysRemaining := remainingTrialDays(expiresAtUnix, nowUnix)
payload.TrialDaysRemaining = &daysRemaining
return
}
if status.ExpiresAt == nil {
return
}
expiresAt, err := time.Parse(time.RFC3339, *status.ExpiresAt)
if err != nil {
return
}
expiresAtUnix := expiresAt.Unix()
payload.TrialExpiresAt = &expiresAtUnix
daysRemaining := remainingTrialDays(expiresAtUnix, nowUnix)
payload.TrialDaysRemaining = &daysRemaining
}
func remainingTrialDays(expiresAtUnix, nowUnix int64) int {
daysRemaining := int(math.Ceil(float64(expiresAtUnix-nowUnix) / 86400.0))
if daysRemaining < 0 {
daysRemaining = 0
}
return daysRemaining
}
// LimitState returns the over-limit UX state string.
func LimitState(current, limit int64) string {
if limit <= 0 {
return "ok" // unlimited
}
if current >= limit {
return "enforced"
}
// For small limits (≤10, but >1), warn at N-1 so users get notice before hitting the wall.
// For larger limits, use 90% threshold.
if limit > 1 && limit <= 10 {
if current >= limit-1 {
return "warning"
}
} else if current*10 >= limit*9 {
return "warning"
}
return "ok"
}