diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 7d760fef3..bfc1b5604 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -247,6 +247,15 @@ Insights`, rather than reviving generic `AI Patrol` or `AI ... analysis` value must come from optional extras, hosted convenience, business workflow, support, or similar non-core surfaces rather than using monitored-system volume itself as the primary paid gate. +9. Keep migrated self-hosted Community/free billing state uncapped even when + the persisted file still carries legacy v5 commercial limit keys: + `pkg/licensing/billing_state_normalization.go` and + `pkg/licensing/database_source.go` must scrub stale + `max_monitored_systems` and `max_guests` values for community/free + billing-state plan labels before runtime-capability, entitlement, or + warning-banner payloads are built, while leaving non-community plan labels + available for bounded hosted or legacy continuity contracts that still + carry explicit monitored-system ceilings. ## Current State diff --git a/internal/api/entitlement_handlers_test.go b/internal/api/entitlement_handlers_test.go index f265723fe..7d50cf3b5 100644 --- a/internal/api/entitlement_handlers_test.go +++ b/internal/api/entitlement_handlers_test.go @@ -494,6 +494,59 @@ func TestEntitlementHandler_GrandfatheredRecurringEvaluatorStateIsUncapped(t *te } } +func TestHandleRuntimeCapabilities_HostedCommunityEvaluatorStateStripsLegacyCommercialCaps(t *testing.T) { + baseDir := t.TempDir() + mtp := config.NewMultiTenantPersistence(baseDir) + + orgID := "test-hosted-community-runtime-capabilities" + if _, err := mtp.GetPersistence(orgID); err != nil { + t.Fatalf("GetPersistence(%s) failed: %v", orgID, err) + } + + store := config.NewFileBillingStore(baseDir) + if err := store.SaveBillingState(orgID, &entitlements.BillingState{ + Capabilities: []string{ + license.FeatureAIPatrol, + }, + Limits: map[string]int64{ + "max_monitored_systems": 1, + "max_guests": 5, + }, + PlanVersion: "community", + SubscriptionState: entitlements.SubStateActive, + }); err != nil { + t.Fatalf("SaveBillingState(%s) failed: %v", orgID, err) + } + + h := NewLicenseHandlers(mtp, true) + + req := httptest.NewRequest(http.MethodGet, "/api/license/runtime-capabilities", nil) + req = req.WithContext(context.WithValue(req.Context(), OrgIDContextKey, orgID)) + rec := httptest.NewRecorder() + h.HandleRuntimeCapabilities(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status=%d, want %d", rec.Code, http.StatusOK) + } + + var payload RuntimeCapabilitiesPayload + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("unmarshal payload failed: %v", err) + } + + for _, limit := range payload.Limits { + if limit.Key == "max_monitored_systems" || limit.Key == "max_guests" { + t.Fatalf("expected runtime capabilities to omit stale commercial caps, got %+v", payload.Limits) + } + } + if payload.MonitoredSystemCapacity == nil { + t.Fatal("expected monitored_system_capacity in runtime capabilities payload") + } + if payload.MonitoredSystemCapacity.Limit != 0 { + t.Fatalf("monitored_system_capacity.limit=%d, want %d", payload.MonitoredSystemCapacity.Limit, 0) + } +} + func TestEntitlementHandler_TrialEligibility_FreshOrgAllowed(t *testing.T) { baseDir := t.TempDir() mtp := config.NewMultiTenantPersistence(baseDir) diff --git a/pkg/licensing/billing_state_normalization.go b/pkg/licensing/billing_state_normalization.go index 56d3c1e93..6b2bfdf48 100644 --- a/pkg/licensing/billing_state_normalization.go +++ b/pkg/licensing/billing_state_normalization.go @@ -48,6 +48,9 @@ func NormalizeBillingState(state *BillingState) *BillingState { normalized.CommercialMigration = NormalizeCommercialMigrationStatus(normalized.CommercialMigration) normalized.Limits = NormalizeMonitoredSystemLimits(normalized.Limits) + if IsSelfHostedCommunityPlanVersion(normalized.PlanVersion) { + stripLegacyCommercialCaps(normalized.Limits) + } // Ensure slices/maps are never nil (JSON marshals as [] / {} instead of null). if normalized.Capabilities == nil { @@ -78,6 +81,9 @@ func NormalizeBillingState(state *BillingState) *BillingState { func billingStateStoredMonitoredSystemLimit(planVersion string) (int, bool) { planVersion = CanonicalizePlanVersion(planVersion) + if IsSelfHostedCommunityPlanVersion(planVersion) { + return 0, false + } if IsGrandfatheredRecurringV5PlanVersion(planVersion) { return UnknownPlanDefaultMonitoredSystemLimit, true } diff --git a/pkg/licensing/billing_state_normalization_test.go b/pkg/licensing/billing_state_normalization_test.go index d64bfa7c9..fdd9c8ba6 100644 --- a/pkg/licensing/billing_state_normalization_test.go +++ b/pkg/licensing/billing_state_normalization_test.go @@ -349,6 +349,36 @@ func TestNormalizeBillingState_PreservesNonCloudPlanLimits(t *testing.T) { } } +func TestNormalizeBillingState_SelfHostedCommunityPlanStripsLegacyCommercialCaps(t *testing.T) { + state := &BillingState{ + PlanVersion: " community ", + Limits: map[string]int64{ + "max_monitored_systems": 1, + "max_guests": 5, + "max_nodes": 99, + "max_reports": 7, + }, + SubscriptionState: SubStateActive, + } + + normalized := NormalizeBillingState(state) + if normalized.PlanVersion != "community" { + t.Fatalf("plan_version=%q, want %q", normalized.PlanVersion, "community") + } + if _, ok := normalized.Limits["max_monitored_systems"]; ok { + t.Fatalf("expected max_monitored_systems to be scrubbed, got %v", normalized.Limits) + } + if _, ok := normalized.Limits["max_guests"]; ok { + t.Fatalf("expected max_guests to be scrubbed, got %v", normalized.Limits) + } + if _, ok := normalized.Limits["max_nodes"]; ok { + t.Fatalf("expected max_nodes to be removed during normalization, got %v", normalized.Limits) + } + if got := normalized.Limits["max_reports"]; got != 7 { + t.Fatalf("limits[max_reports]=%d, want %d", got, 7) + } +} + func TestNormalizeBillingState_StripsEntitlementsForRevokedSubscriptions(t *testing.T) { state := &BillingState{ Capabilities: []string{"relay"}, diff --git a/pkg/licensing/database_source.go b/pkg/licensing/database_source.go index 12907f10c..716a2622d 100644 --- a/pkg/licensing/database_source.go +++ b/pkg/licensing/database_source.go @@ -243,9 +243,9 @@ func normalizeDatabaseSourceState(state BillingState) BillingState { normalized.CommercialMigration = NormalizeCommercialMigrationStatus(normalized.CommercialMigration) normalized.Limits = NormalizeMonitoredSystemLimits(normalized.Limits) - if IsGrandfatheredRecurringV5PlanVersion(normalized.PlanVersion) { - delete(normalized.Limits, MaxMonitoredSystemsLicenseGateKey) - delete(normalized.Limits, "max_guests") + if IsSelfHostedCommunityPlanVersion(normalized.PlanVersion) || + IsGrandfatheredRecurringV5PlanVersion(normalized.PlanVersion) { + stripLegacyCommercialCaps(normalized.Limits) } switch normalized.SubscriptionState { diff --git a/pkg/licensing/database_source_test.go b/pkg/licensing/database_source_test.go index f98ebc76e..26c1ffaa2 100644 --- a/pkg/licensing/database_source_test.go +++ b/pkg/licensing/database_source_test.go @@ -148,6 +148,32 @@ func TestDatabaseSourceGrandfatheredRecurringPlanStripsCappedLimits(t *testing.T } } +func TestDatabaseSourceSelfHostedCommunityPlanStripsLegacyCommercialCaps(t *testing.T) { + store := &mockBillingStore{ + state: &BillingState{ + PlanVersion: "community", + Limits: map[string]int64{ + "max_monitored_systems": 1, + "max_guests": 5, + "max_reports": 7, + }, + SubscriptionState: SubStateActive, + }, + } + + source := NewDatabaseSource(store, "org-1", time.Hour) + + if got := source.PlanVersion(); got != "community" { + t.Fatalf("expected plan_version %q, got %q", "community", got) + } + if got := source.SubscriptionState(); got != SubStateActive { + t.Fatalf("expected subscription_state %q, got %q", SubStateActive, got) + } + if got := source.Limits(); !reflect.DeepEqual(got, map[string]int64{"max_reports": 7}) { + t.Fatalf("expected community plan to scrub legacy commercial caps, got limits %v", got) + } +} + func TestDatabaseSourcePreservesMissingPlanVersion(t *testing.T) { store := &mockBillingStore{ state: &BillingState{ diff --git a/pkg/licensing/features.go b/pkg/licensing/features.go index 5d7d861dd..298534e24 100644 --- a/pkg/licensing/features.go +++ b/pkg/licensing/features.go @@ -6,6 +6,7 @@ package licensing import ( "sort" + "strings" "time" ) @@ -160,6 +161,25 @@ func IsGrandfatheredRecurringV5PlanVersion(planVersion string) bool { } } +// IsSelfHostedCommunityPlanVersion reports whether a persisted billing-state +// plan version denotes the uncapped self-hosted Community/free posture. This +// is narrower than tier-based self-hosted licensing: billing-state plan labels +// like "pro" can still carry explicit continuity limits in hosted and legacy +// migration paths, so only community/free variants are safe to scrub here. +func IsSelfHostedCommunityPlanVersion(planVersion string) bool { + switch strings.ToLower(CanonicalizePlanVersion(planVersion)) { + case "community", string(TierFree): + return true + default: + return false + } +} + +func stripLegacyCommercialCaps(limits map[string]int64) { + delete(limits, MaxMonitoredSystemsLicenseGateKey) + delete(limits, "max_guests") +} + // UnknownPlanDefaultMonitoredSystemLimit is the safe-default monitored-system limit applied when a // plan version is not recognized. Fail-closed: unknown plans get the smallest // tier limit rather than unlimited access. diff --git a/pkg/licensing/features_test.go b/pkg/licensing/features_test.go index 118fb18d3..e9193b101 100644 --- a/pkg/licensing/features_test.go +++ b/pkg/licensing/features_test.go @@ -599,6 +599,35 @@ func TestIsGrandfatheredRecurringV5PlanVersion(t *testing.T) { } } +func TestIsSelfHostedCommunityPlanVersion(t *testing.T) { + tests := []struct { + plan string + want bool + }{ + {plan: "community", want: true}, + {plan: "Community", want: true}, + {plan: "free", want: true}, + {plan: "relay", want: false}, + {plan: "pro", want: false}, + {plan: "pro_plus", want: false}, + {plan: "pro_annual", want: false}, + {plan: "lifetime", want: false}, + {plan: "v5_lifetime_grandfathered", want: false}, + {plan: "cloud_starter", want: false}, + {plan: "trial", want: false}, + {plan: "pro-v2", want: false}, + {plan: "", want: false}, + } + + for _, tt := range tests { + t.Run(tt.plan, func(t *testing.T) { + if got := IsSelfHostedCommunityPlanVersion(tt.plan); got != tt.want { + t.Fatalf("IsSelfHostedCommunityPlanVersion(%q) = %v, want %v", tt.plan, got, tt.want) + } + }) + } +} + // TestPriceIDToPlanVersion_AllMapToKnownPlans ensures every plan version in the // price→plan map is recognized by LimitsForCloudPlan (fail-closed safety net). func TestPriceIDToPlanVersion_AllMapToKnownPlans(t *testing.T) {