From b84e4067e850582c777d5364a0ca1a04c26af370 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Tue, 14 Apr 2026 23:34:37 +0100 Subject: [PATCH] Uncap grandfathered lifetime entitlements --- docs/PULSE_PRO.md | 4 +- .../v6/internal/subsystems/cloud-paid.md | 4 ++ .../__tests__/ProLicensePanel.test.tsx | 32 ++++++++++++-- internal/api/contract_test.go | 43 +++++++++++++++++++ pkg/licensing/entitlement_payload_test.go | 20 +++++++++ pkg/licensing/features.go | 2 +- pkg/licensing/features_test.go | 2 +- pkg/licensing/models.go | 6 +++ pkg/licensing/models_test.go | 10 +++++ .../tests/12-v5-commercial-migration.spec.ts | 14 +++--- 10 files changed, 124 insertions(+), 13 deletions(-) diff --git a/docs/PULSE_PRO.md b/docs/PULSE_PRO.md index e0435da2ac..d515aa52d9 100644 --- a/docs/PULSE_PRO.md +++ b/docs/PULSE_PRO.md @@ -7,7 +7,7 @@ For the canonical, code-aligned entitlement table (including internal tier names ## Plan Mapping (User-Facing -> Code Tiers) -Pulse uses capability keys (for example, `ai_autofix`) to gate features at runtime. Those capabilities are bundled into internal tiers in `internal/license/features.go`. +Pulse uses capability keys (for example, `ai_autofix`) to gate features at runtime. Those capabilities are bundled into internal tiers in `pkg/licensing/features.go`. User-facing plans map to internal tiers as follows: - **Community**: `free` @@ -17,7 +17,7 @@ User-facing plans map to internal tiers as follows: - **Cloud**: `msp` or `enterprise` Notes: -- `lifetime` keeps the same runtime entitlements as Pro. +- `lifetime` keeps the same runtime feature set as Pro, but grandfathered lifetime entitlements are uncapped for monitored systems and guest access. - Items marked **Cloud*** require the `enterprise` tier rather than the base `msp` tier. - If you are self-hosting, you can use capability keys and `GET /api/license/features` to discover exactly what is active in your instance. diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 4f199ba6c4..7f031af58a 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -184,6 +184,10 @@ still grows monitored-system usage. Patrol quickstart may be presented only as Patrol-only first-run activation support backed by the license server, while Relay and Pro remain the canonical commercial story. +5. Treat grandfathered `lifetime` licenses as uncapped commercial entitlements: + they keep the Pro feature set, but they must not inherit monitored-system or + guest caps from recurring Pro contracts anywhere in runtime, issuance, or + migrated-license storage. ## Current State diff --git a/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx b/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx index 3dd845319b..1c682d2119 100644 --- a/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx @@ -246,9 +246,9 @@ describe('ProLicensePanel', () => { subscription_state: 'active', upgrade_reasons: [], tier: 'pro', - plan_version: 'v5_lifetime_grandfathered', + plan_version: 'v5_pro_monthly_grandfathered', licensed_email: 'owner@example.com', - is_lifetime: true, + is_lifetime: false, trial_eligible: false, }; @@ -261,7 +261,7 @@ describe('ProLicensePanel', () => { expect(screen.getByText('5 / 12')).toBeInTheDocument(); expect(screen.getByText('7')).toBeInTheDocument(); expect(screen.getAllByText('Active').length).toBeGreaterThan(0); - expect(screen.getByText('V5 Lifetime Grandfathered')).toBeInTheDocument(); + expect(screen.getByText('V5 Pro Monthly (Grandfathered)')).toBeInTheDocument(); expect(screen.getByText('Included Monitored Systems')).toBeInTheDocument(); expect(screen.getByRole('tab', { name: 'Plan' })).toHaveAttribute('aria-selected', 'true'); expect(screen.getByRole('tab', { name: 'Usage' })).toHaveAttribute('aria-selected', 'false'); @@ -274,6 +274,32 @@ describe('ProLicensePanel', () => { }); }); + it('shows lifetime grandfathered plans as uncapped', async () => { + mockEntitlements = { + capabilities: ['ai_patrol'], + limits: [], + subscription_state: 'active', + upgrade_reasons: [], + tier: 'lifetime', + plan_version: 'v5_lifetime_grandfathered', + licensed_email: 'owner@example.com', + is_lifetime: true, + trial_eligible: false, + }; + + renderPanel(); + + await waitFor(() => { + expect(screen.getByText('Plan Terms')).toBeInTheDocument(); + }); + + expect(screen.getByText('V5 Lifetime Grandfathered')).toBeInTheDocument(); + expect(screen.getByText('Included Monitored Systems')).toBeInTheDocument(); + expect(screen.getByText('Max Guests')).toBeInTheDocument(); + expect(screen.getAllByText('Unlimited').length).toBeGreaterThan(0); + expect(screen.queryByText('5 / 12')).not.toBeInTheDocument(); + }); + it('shows recurring grandfathered pricing continuity for migrated v5 Pro plans', async () => { const tests = [ { diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 583f5a1cfa..6509b50919 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -5398,6 +5398,49 @@ func TestContract_EntitlementPayloadMonitoredSystemUsageUnavailableJSONSnapshot( assertJSONSnapshot(t, got, want) } +func TestContract_EntitlementPayloadLifetimeJSONSnapshot(t *testing.T) { + payload := buildEntitlementPayloadWithUsage(&licenseStatus{ + Valid: true, + Tier: pkglicensing.TierLifetime, + PlanVersion: "v5_lifetime_grandfathered", + IsLifetime: true, + Features: append([]string(nil), pkglicensing.TierFeatures[pkglicensing.TierLifetime]...), + MaxMonitoredSystems: 0, + MaxGuests: 0, + }, string(pkglicensing.SubStateActive), entitlementUsageSnapshot{ + MonitoredSystems: 15, + MonitoredSystemsAvailable: true, + LegacyConnections: legacyConnectionCountsModel{ + ProxmoxNodes: 1, + DockerHosts: 1, + }, + }, nil) + + got, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal entitlement payload: %v", err) + } + + const want = `{ + "capabilities":["update_alerts","sso","ai_patrol","relay","mobile_app","push_notifications","long_term_metrics","ai_alerts","ai_autofix","kubernetes_ai","agent_profiles","advanced_sso","rbac","audit_logging","advanced_reporting"], + "limits":[], + "subscription_state":"active", + "upgrade_reasons":[], + "plan_version":"v5_lifetime_grandfathered", + "tier":"lifetime", + "hosted_mode":false, + "valid":true, + "is_lifetime":true, + "days_remaining":0, + "trial_eligible":false, + "max_history_days":90, + "legacy_connections":{"proxmox_nodes":1,"docker_hosts":1,"kubernetes_clusters":0}, + "has_migration_gap":false + }` + + assertJSONSnapshot(t, got, want) +} + func TestContract_EntitlementUsageSnapshotWaitsForSettledSupplementalInventory(t *testing.T) { provider := &contractSupplementalUsageProvider{} monitor := &monitoring.Monitor{} diff --git a/pkg/licensing/entitlement_payload_test.go b/pkg/licensing/entitlement_payload_test.go index 59a41466e3..6638cf94f0 100644 --- a/pkg/licensing/entitlement_payload_test.go +++ b/pkg/licensing/entitlement_payload_test.go @@ -325,6 +325,26 @@ func TestBuildEntitlementPayload_CopiesStatusDisplayFields(t *testing.T) { } } +func TestBuildEntitlementPayload_LifetimeOmitsCommercialCaps(t *testing.T) { + payload := BuildEntitlementPayloadWithUsage(&LicenseStatus{ + Valid: true, + Tier: TierLifetime, + PlanVersion: "v5_lifetime_grandfathered", + IsLifetime: true, + Features: append([]string(nil), TierFeatures[TierLifetime]...), + MaxMonitoredSystems: 0, + MaxGuests: 0, + }, string(SubStateActive), EntitlementUsageSnapshot{ + MonitoredSystems: 15, + MonitoredSystemsAvailable: true, + Guests: 4, + }, nil) + + if len(payload.Limits) != 0 { + t.Fatalf("expected lifetime entitlements to omit commercial caps, got %+v", payload.Limits) + } +} + func TestBuildEntitlementPayload_MaxHistoryDays(t *testing.T) { tests := []struct { name string diff --git a/pkg/licensing/features.go b/pkg/licensing/features.go index edf42d01ee..381de06ca2 100644 --- a/pkg/licensing/features.go +++ b/pkg/licensing/features.go @@ -67,7 +67,7 @@ var TierMonitoredSystemLimits = map[Tier]int{ TierPro: 15, TierProPlus: 50, TierProAnnual: 15, // Legacy: same as Pro - TierLifetime: 15, // Legacy: same as Pro + TierLifetime: 0, // Grandfathered lifetime entitlements remain uncapped TierCloud: 0, // Cloud tiers have per-plan limits set in license claims TierMSP: 0, // MSP tiers have per-plan pool limits set in license claims TierEnterprise: 0, // Custom diff --git a/pkg/licensing/features_test.go b/pkg/licensing/features_test.go index b393fba3bf..419009925b 100644 --- a/pkg/licensing/features_test.go +++ b/pkg/licensing/features_test.go @@ -265,7 +265,7 @@ func TestTierMonitoredSystemLimits(t *testing.T) { {TierPro, 15}, {TierProPlus, 50}, {TierProAnnual, 15}, - {TierLifetime, 15}, + {TierLifetime, 0}, } for _, tt := range tests { diff --git a/pkg/licensing/models.go b/pkg/licensing/models.go index 2b77be0b61..df78c40415 100644 --- a/pkg/licensing/models.go +++ b/pkg/licensing/models.go @@ -80,6 +80,12 @@ func (c Claims) EffectiveLimits() map[string]int64 { limits["max_guests"] = int64(c.MaxGuests) } } + if c.Tier == TierLifetime { + // Grandfathered lifetime licenses must remain uncapped even if older + // tokens or migrated records still carry historical Pro-era limits. + delete(limits, MaxMonitoredSystemsLicenseGateKey) + delete(limits, "max_guests") + } if c.Tier == TierCloud || c.Tier == TierMSP { if limit, known := CloudPlanMonitoredSystemLimits[CanonicalizePlanVersion(c.PlanVersion)]; known { limits[MaxMonitoredSystemsLicenseGateKey] = int64(limit) diff --git a/pkg/licensing/models_test.go b/pkg/licensing/models_test.go index 89c5e8b261..54169086e1 100644 --- a/pkg/licensing/models_test.go +++ b/pkg/licensing/models_test.go @@ -96,6 +96,16 @@ func TestClaims_EffectiveLimits(t *testing.T) { claims: Claims{}, expected: map[string]int64{}, }, + { + name: "lifetime_strips_legacy_caps", + claims: Claims{ + Tier: TierLifetime, + Limits: map[string]int64{"max_monitored_systems": 15, "max_guests": 5}, + MaxMonitoredSystems: 15, + MaxGuests: 5, + }, + expected: map[string]int64{}, + }, } for _, tt := range tests { diff --git a/tests/integration/tests/12-v5-commercial-migration.spec.ts b/tests/integration/tests/12-v5-commercial-migration.spec.ts index b2f1c44164..c33565b19e 100644 --- a/tests/integration/tests/12-v5-commercial-migration.spec.ts +++ b/tests/integration/tests/12-v5-commercial-migration.spec.ts @@ -157,10 +157,12 @@ test.describe.serial('v5 commercial migration notice', () => { expect(entitlements.licensed_email).toBe(expectedLicensedEmail); expect(entitlements.is_lifetime).toBe(expectedIsLifetime === 'true'); expect(entitlements.trial_eligible).toBe(false); - if (expectedMaxMonitoredSystems > 0) { - const monitoredSystemLimit = entitlements.limits?.find( - (limit) => limit.key === 'max_monitored_systems', - ); + const monitoredSystemLimit = entitlements.limits?.find( + (limit) => limit.key === 'max_monitored_systems', + ); + if (expectedIsLifetime === 'true') { + expect(monitoredSystemLimit).toBeUndefined(); + } else if (expectedMaxMonitoredSystems > 0) { expect(monitoredSystemLimit?.limit).toBe(expectedMaxMonitoredSystems); } return; @@ -192,8 +194,8 @@ test.describe.serial('v5 commercial migration notice', () => { await expectFieldValue(expectFieldLocator(page, 'Plan Terms'), formatTitleCase(expectedPlanVersion)); if (expectedIsLifetime === 'true') { await expectFieldValue(expectFieldLocator(page, 'Expires'), 'Never (Lifetime)'); - } - if (expectedMaxMonitoredSystems > 0) { + await expectFieldValue(expectFieldLocator(page, 'Included Monitored Systems'), 'Unlimited'); + } else if (expectedMaxMonitoredSystems > 0) { await expectFieldValue( expectFieldLocator(page, 'Included Monitored Systems'), String(expectedMaxMonitoredSystems),