From b4692ef7f04666e7a0309de93cee937b7199e933 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 23 Apr 2026 15:30:42 +0100 Subject: [PATCH] Encode canonical self-hosted feature metadata --- .../v6/internal/subsystems/cloud-paid.md | 13 + .../v6/internal/subsystems/registry.json | 20 + .../__tests__/licensePresentation.test.ts | 2 + .../utils/__tests__/selfHostedPlans.test.ts | 20 + .../src/utils/licensePresentation.ts | 38 +- .../selfHostedFeatureCatalog.generated.ts | 399 ++++++++++++++++++ frontend-modern/src/utils/selfHostedPlans.ts | 147 ++----- pkg/licensing/features.go | 58 +-- pkg/licensing/features_test.go | 40 ++ pkg/licensing/self_hosted_feature_catalog.go | 393 +++++++++++++++++ pkg/licensing/upgrade_reasons.go | 81 +--- pkg/licensing/upgrade_test.go | 26 ++ .../generate-self-hosted-feature-catalog.go | 269 ++++++++++++ 13 files changed, 1244 insertions(+), 262 deletions(-) create mode 100644 frontend-modern/src/utils/selfHostedFeatureCatalog.generated.ts create mode 100644 pkg/licensing/self_hosted_feature_catalog.go create mode 100644 scripts/generate-self-hosted-feature-catalog.go diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index b1d4b9df11..a30d4f9f31 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -96,6 +96,9 @@ agreement, and cloud-specific enforcement rules. 74. `pkg/licensing/trial_activation_public_key_override_dev.go` 75. `pkg/licensing/trial_activation_public_key_override_release.go` 76. `pkg/licensing/testing_helpers.go` +77. `pkg/licensing/self_hosted_feature_catalog.go` +78. `frontend-modern/src/utils/selfHostedFeatureCatalog.generated.ts` +79. `pulse-pro:license-server/self_hosted_feature_catalog.generated.go` ## Shared Boundaries @@ -298,6 +301,16 @@ Community limit enforcement. `incident memory`, `scheduled remediations`, and `execution audit trail` must likewise stay out of current v6 commercial copy unless Pulse ships a first-class product surface that makes those names truthful again. + That same self-hosted pricing boundary now also owns canonical feature + classification metadata. `pkg/licensing/self_hosted_feature_catalog.go` + is the source of truth for customer-facing self-hosted feature labels, + comparison visibility, plan roles (`primary_pillar`, `included_extra`, + `compatibility_only`, and hidden/included states), and generic upgrade + reasons. `frontend-modern/src/utils/selfHostedFeatureCatalog.generated.ts` + and `pulse-pro:license-server/self_hosted_feature_catalog.generated.go` + are generated projections of that source for app and public-pricing + consumers; those projections must not drift through hand-maintained + parallel lists or page-local renaming. 26. Keep hosted trial-activation verifier source selection compile-time owned. `pkg/licensing/trial_activation.go`, `pkg/licensing/trial_activation_public_key_override_dev.go`, and diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index b82e2c7f1f..484b4fccb5 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -1710,6 +1710,7 @@ "frontend-modern/src/utils/licensePresentation.ts", "frontend-modern/src/utils/monitoredSystemPresentation.ts", "frontend-modern/src/utils/pricingHandoff.ts", + "frontend-modern/src/utils/selfHostedFeatureCatalog.generated.ts", "frontend-modern/src/utils/selfHostedPlans.ts", "frontend-modern/src/utils/upgradePresentation.ts", "internal/api/public_signup_handlers.go", @@ -1731,6 +1732,7 @@ "pulse-pro:license-server/checkout_funnel.go", "pulse-pro:license-server/grandfathering.go", "pulse-pro:license-server/main.go", + "pulse-pro:license-server/self_hosted_feature_catalog.generated.go", "pulse-pro:license-server/v6_checkout.go", "pulse-pro:license-server/v6_handlers.go", "pulse-pro:scripts/audit_v6_preview_demo.sh", @@ -1798,6 +1800,24 @@ "pkg/licensing/grant_claims_contract_test.go" ] }, + { + "id": "self-hosted-feature-catalog", + "label": "self-hosted feature catalog proof", + "match_prefixes": [], + "match_files": [ + "frontend-modern/src/utils/selfHostedFeatureCatalog.generated.ts", + "pkg/licensing/self_hosted_feature_catalog.go", + "pulse-pro:license-server/self_hosted_feature_catalog.generated.go" + ], + "allow_same_subsystem_tests": false, + "test_prefixes": [], + "exact_files": [ + "frontend-modern/src/utils/__tests__/licensePresentation.test.ts", + "frontend-modern/src/utils/__tests__/selfHostedPlans.test.ts", + "pkg/licensing/features_test.go", + "pkg/licensing/upgrade_test.go" + ] + }, { "id": "stripe-plan-derivation", "label": "stripe plan derivation proof", diff --git a/frontend-modern/src/utils/__tests__/licensePresentation.test.ts b/frontend-modern/src/utils/__tests__/licensePresentation.test.ts index 1eca1aa14f..be7153dcaf 100644 --- a/frontend-modern/src/utils/__tests__/licensePresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/licensePresentation.test.ts @@ -118,6 +118,8 @@ describe('licensePresentation', () => { it('returns canonical feature labels', () => { expect(getLicenseFeatureLabel('ai_patrol')).toBe('Pulse Patrol'); expect(getLicenseFeatureLabel('mobile_app')).toBe('Mobile App Access'); + expect(getLicenseFeatureLabel('update_alerts')).toBe('Update Alerts'); + expect(getLicenseFeatureLabel('relay')).toBe('Pulse Relay (Remote Access)'); expect(getLicenseFeatureLabel('custom_feature')).toBe('Custom Feature'); }); diff --git a/frontend-modern/src/utils/__tests__/selfHostedPlans.test.ts b/frontend-modern/src/utils/__tests__/selfHostedPlans.test.ts index e4dfb902ae..df1fdcc90e 100644 --- a/frontend-modern/src/utils/__tests__/selfHostedPlans.test.ts +++ b/frontend-modern/src/utils/__tests__/selfHostedPlans.test.ts @@ -26,6 +26,26 @@ describe('selfHostedPlans', () => { relay: 'Unlimited', pro: 'Unlimited', }); + + expect(SELF_HOSTED_FEATURE_ROWS).toEqual( + expect.arrayContaining([ + { + key: 'update_alerts', + name: 'Update Alerts', + community: true, + relay: true, + pro: true, + }, + { + key: 'relay', + name: 'Pulse Relay (Remote Access)', + community: false, + relay: true, + pro: true, + }, + ]), + ); + expect(SELF_HOSTED_FEATURE_ROWS.find((row) => row.key === 'kubernetes_ai')).toBeUndefined(); }); it('keeps shared self-hosted commercial copy in the common contract', () => { diff --git a/frontend-modern/src/utils/licensePresentation.ts b/frontend-modern/src/utils/licensePresentation.ts index f7e568fc25..655c8248ac 100644 --- a/frontend-modern/src/utils/licensePresentation.ts +++ b/frontend-modern/src/utils/licensePresentation.ts @@ -15,6 +15,10 @@ import { type MonitoredSystemLimitUsageStatus, } from '@/utils/monitoredSystemPresentation'; import { getSelfHostedPlanDefinitionForBillingTier } from '@/utils/selfHostedPlans'; +import { + getSelfHostedFeatureCatalogEntry, + isDisplayableSelfHostedFeatureKey, +} from '@/utils/selfHostedFeatureCatalog.generated'; import { titleCaseDelimitedLabel } from '@/utils/textPresentation'; const TIER_LABELS: Record = { @@ -33,28 +37,6 @@ const SELF_HOSTED_PLAN_LABELS: Record = { pro: 'Pulse Pro', }; -const FEATURE_LABELS: Record = { - ai_patrol: 'Pulse Patrol', - ai_alerts: 'Pulse Alert Analysis', - ai_autofix: 'Patrol Auto-Fix', - kubernetes_ai: 'Kubernetes AI Analysis (Compatibility)', - update_alerts: 'Update Alerts', - sso: 'Basic SSO (OIDC)', - advanced_sso: 'Advanced SSO (SAML/Multi-Provider)', - rbac: 'Role-Based Access Control (RBAC)', - audit_logging: 'Audit Logging', - advanced_reporting: 'PDF/CSV Reporting', - agent_profiles: 'Centralized Agent Profiles', - relay: 'Pulse Relay (Remote Access)', - mobile_app: 'Mobile App Access', - push_notifications: 'Push Notifications', - long_term_metrics: 'Extended Metric History', - multi_user: 'Multi-User Mode', - white_label: 'White-Label Branding', - multi_tenant: 'Multi-Tenant Mode', - unlimited: 'Unlimited Instances', -}; - const FEATURE_MIN_TIER_LABELS: Record = { relay: 'Relay', mobile_app: 'Relay', @@ -62,13 +44,6 @@ const FEATURE_MIN_TIER_LABELS: Record = { multi_tenant: 'MSP', }; -const NON_DISPLAYABLE_FEATURES = new Set([ - 'kubernetes_ai', - 'multi_user', - 'white_label', - 'unlimited', -]); - export interface LicenseSubscriptionStatusPresentation { label: string; badgeClass: string; @@ -179,13 +154,14 @@ export const getSelfHostedPlanLabel = (tier?: string | null): string => { export const getLicenseFeatureLabel = (feature?: string | null): string => { const normalized = (feature || '').trim().toLowerCase(); if (!normalized) return 'Unknown'; - return FEATURE_LABELS[normalized] || titleCaseDelimitedLabel(normalized); + const entry = getSelfHostedFeatureCatalogEntry(normalized); + return entry?.comparisonName || titleCaseDelimitedLabel(normalized); }; export const isDisplayableLicenseFeature = (feature?: string | null): boolean => { const normalized = (feature || '').trim().toLowerCase(); if (!normalized) return false; - return !NON_DISPLAYABLE_FEATURES.has(normalized); + return isDisplayableSelfHostedFeatureKey(normalized); }; export const getFeatureMinTierLabel = (feature?: string | null): string => { diff --git a/frontend-modern/src/utils/selfHostedFeatureCatalog.generated.ts b/frontend-modern/src/utils/selfHostedFeatureCatalog.generated.ts new file mode 100644 index 0000000000..c6e999411c --- /dev/null +++ b/frontend-modern/src/utils/selfHostedFeatureCatalog.generated.ts @@ -0,0 +1,399 @@ +// This file is generated by scripts/generate-self-hosted-feature-catalog.go. +// Do not edit it manually. + +export type SelfHostedFeatureRole = + | 'hidden' + | 'included' + | 'primary_pillar' + | 'included_extra' + | 'compatibility_only'; + +export interface GeneratedSelfHostedFeatureCatalogEntry { + key: string; + displayName: string; + comparisonName: string; + showInComparisonTable: boolean; + displayableInSelfHostedPlan: boolean; + roles: { + community: SelfHostedFeatureRole; + relay: SelfHostedFeatureRole; + pro: SelfHostedFeatureRole; + }; + includedIn: { + community: boolean; + relay: boolean; + pro: boolean; + }; +} + +export const SELF_HOSTED_FEATURE_CATALOG: readonly GeneratedSelfHostedFeatureCatalogEntry[] = [ + { + "key": "update_alerts", + "displayName": "Update Alerts (Container/Package Updates)", + "comparisonName": "Update Alerts", + "showInComparisonTable": true, + "displayableInSelfHostedPlan": true, + "roles": { + "community": "included", + "relay": "included", + "pro": "included" + }, + "includedIn": { + "community": true, + "relay": true, + "pro": true + } + }, + { + "key": "sso", + "displayName": "Basic SSO (OIDC)", + "comparisonName": "Basic SSO (OIDC)", + "showInComparisonTable": true, + "displayableInSelfHostedPlan": true, + "roles": { + "community": "included", + "relay": "included", + "pro": "included" + }, + "includedIn": { + "community": true, + "relay": true, + "pro": true + } + }, + { + "key": "ai_patrol", + "displayName": "Pulse Patrol (Background Health Checks)", + "comparisonName": "Pulse Patrol", + "showInComparisonTable": true, + "displayableInSelfHostedPlan": true, + "roles": { + "community": "included", + "relay": "included", + "pro": "included" + }, + "includedIn": { + "community": true, + "relay": true, + "pro": true + } + }, + { + "key": "relay", + "displayName": "Pulse Relay (Remote Access)", + "comparisonName": "Pulse Relay (Remote Access)", + "showInComparisonTable": true, + "displayableInSelfHostedPlan": true, + "roles": { + "community": "hidden", + "relay": "primary_pillar", + "pro": "included" + }, + "includedIn": { + "community": false, + "relay": true, + "pro": true + } + }, + { + "key": "mobile_app", + "displayName": "Mobile App Access", + "comparisonName": "Mobile App Access", + "showInComparisonTable": true, + "displayableInSelfHostedPlan": true, + "roles": { + "community": "hidden", + "relay": "primary_pillar", + "pro": "included" + }, + "includedIn": { + "community": false, + "relay": true, + "pro": true + } + }, + { + "key": "push_notifications", + "displayName": "Push Notifications", + "comparisonName": "Push Notifications", + "showInComparisonTable": true, + "displayableInSelfHostedPlan": true, + "roles": { + "community": "hidden", + "relay": "primary_pillar", + "pro": "included" + }, + "includedIn": { + "community": false, + "relay": true, + "pro": true + } + }, + { + "key": "ai_alerts", + "displayName": "Alert Analysis", + "comparisonName": "Pulse Alert Analysis", + "showInComparisonTable": true, + "displayableInSelfHostedPlan": true, + "roles": { + "community": "hidden", + "relay": "hidden", + "pro": "primary_pillar" + }, + "includedIn": { + "community": false, + "relay": false, + "pro": true + } + }, + { + "key": "ai_autofix", + "displayName": "Pulse Patrol Auto-Fix", + "comparisonName": "Patrol Auto-Fix", + "showInComparisonTable": true, + "displayableInSelfHostedPlan": true, + "roles": { + "community": "hidden", + "relay": "hidden", + "pro": "primary_pillar" + }, + "includedIn": { + "community": false, + "relay": false, + "pro": true + } + }, + { + "key": "long_term_metrics", + "displayName": "Extended Metric History", + "comparisonName": "Extended Metric History", + "showInComparisonTable": false, + "displayableInSelfHostedPlan": true, + "roles": { + "community": "hidden", + "relay": "primary_pillar", + "pro": "primary_pillar" + }, + "includedIn": { + "community": false, + "relay": true, + "pro": true + } + }, + { + "key": "advanced_sso", + "displayName": "Advanced SSO (SAML/Multi-Provider)", + "comparisonName": "Advanced SSO (SAML/Multi-Provider)", + "showInComparisonTable": true, + "displayableInSelfHostedPlan": true, + "roles": { + "community": "hidden", + "relay": "hidden", + "pro": "included_extra" + }, + "includedIn": { + "community": false, + "relay": false, + "pro": true + } + }, + { + "key": "rbac", + "displayName": "Role-Based Access Control (RBAC)", + "comparisonName": "Role-Based Access Control (RBAC)", + "showInComparisonTable": true, + "displayableInSelfHostedPlan": true, + "roles": { + "community": "hidden", + "relay": "hidden", + "pro": "included_extra" + }, + "includedIn": { + "community": false, + "relay": false, + "pro": true + } + }, + { + "key": "audit_logging", + "displayName": "Audit Logging", + "comparisonName": "Audit Logging", + "showInComparisonTable": true, + "displayableInSelfHostedPlan": true, + "roles": { + "community": "hidden", + "relay": "hidden", + "pro": "included_extra" + }, + "includedIn": { + "community": false, + "relay": false, + "pro": true + } + }, + { + "key": "advanced_reporting", + "displayName": "PDF/CSV Reporting", + "comparisonName": "PDF/CSV Reporting", + "showInComparisonTable": true, + "displayableInSelfHostedPlan": true, + "roles": { + "community": "hidden", + "relay": "hidden", + "pro": "included_extra" + }, + "includedIn": { + "community": false, + "relay": false, + "pro": true + } + }, + { + "key": "agent_profiles", + "displayName": "Centralized Agent Profiles", + "comparisonName": "Centralized Agent Profiles", + "showInComparisonTable": true, + "displayableInSelfHostedPlan": true, + "roles": { + "community": "hidden", + "relay": "hidden", + "pro": "included_extra" + }, + "includedIn": { + "community": false, + "relay": false, + "pro": true + } + }, + { + "key": "kubernetes_ai", + "displayName": "Kubernetes AI Analysis (Compatibility)", + "comparisonName": "Kubernetes AI Analysis (Compatibility)", + "showInComparisonTable": false, + "displayableInSelfHostedPlan": false, + "roles": { + "community": "hidden", + "relay": "hidden", + "pro": "compatibility_only" + }, + "includedIn": { + "community": false, + "relay": false, + "pro": true + } + }, + { + "key": "multi_user", + "displayName": "Multi-User Mode", + "comparisonName": "Multi-User Mode", + "showInComparisonTable": false, + "displayableInSelfHostedPlan": false, + "roles": { + "community": "hidden", + "relay": "hidden", + "pro": "hidden" + }, + "includedIn": { + "community": false, + "relay": false, + "pro": false + } + }, + { + "key": "white_label", + "displayName": "White-Label Branding", + "comparisonName": "White-Label Branding", + "showInComparisonTable": false, + "displayableInSelfHostedPlan": false, + "roles": { + "community": "hidden", + "relay": "hidden", + "pro": "hidden" + }, + "includedIn": { + "community": false, + "relay": false, + "pro": false + } + }, + { + "key": "multi_tenant", + "displayName": "Multi-Tenant Mode", + "comparisonName": "Multi-Tenant Mode", + "showInComparisonTable": false, + "displayableInSelfHostedPlan": true, + "roles": { + "community": "hidden", + "relay": "hidden", + "pro": "hidden" + }, + "includedIn": { + "community": false, + "relay": false, + "pro": false + } + }, + { + "key": "unlimited", + "displayName": "Unlimited Instances", + "comparisonName": "Unlimited Instances", + "showInComparisonTable": false, + "displayableInSelfHostedPlan": false, + "roles": { + "community": "hidden", + "relay": "hidden", + "pro": "hidden" + }, + "includedIn": { + "community": false, + "relay": false, + "pro": false + } + }, + { + "key": "demo_fixtures", + "displayName": "Demo Fixtures (Internal)", + "comparisonName": "Demo Fixtures (Internal)", + "showInComparisonTable": false, + "displayableInSelfHostedPlan": false, + "roles": { + "community": "hidden", + "relay": "hidden", + "pro": "hidden" + }, + "includedIn": { + "community": false, + "relay": false, + "pro": false + } + } +] as const; + +export const SELF_HOSTED_FEATURE_CATALOG_BY_KEY: Record = Object.fromEntries( + SELF_HOSTED_FEATURE_CATALOG.map((entry) => [entry.key, entry]), +) as Record; + +export function getSelfHostedFeatureCatalogEntry( + key?: string | null, +): GeneratedSelfHostedFeatureCatalogEntry | null { + const normalized = (key || '').trim(); + if (!normalized) { + return null; + } + return SELF_HOSTED_FEATURE_CATALOG_BY_KEY[normalized] || null; +} + +export function isDisplayableSelfHostedFeatureKey(key?: string | null): boolean { + const entry = getSelfHostedFeatureCatalogEntry(key); + if (!entry) { + return true; + } + return entry.displayableInSelfHostedPlan; +} + +export function getSelfHostedFeaturesForRole( + tier: 'community' | 'relay' | 'pro', + role: SelfHostedFeatureRole, +): readonly GeneratedSelfHostedFeatureCatalogEntry[] { + return SELF_HOSTED_FEATURE_CATALOG.filter((entry) => entry.roles[tier] === role); +} diff --git a/frontend-modern/src/utils/selfHostedPlans.ts b/frontend-modern/src/utils/selfHostedPlans.ts index 219dbbde99..91076fdc31 100644 --- a/frontend-modern/src/utils/selfHostedPlans.ts +++ b/frontend-modern/src/utils/selfHostedPlans.ts @@ -1,3 +1,8 @@ +import { + SELF_HOSTED_FEATURE_CATALOG, + getSelfHostedFeaturesForRole, +} from './selfHostedFeatureCatalog.generated'; + export type SelfHostedTierKey = 'community' | 'relay' | 'pro'; export interface SelfHostedPlanDefinition { @@ -42,6 +47,35 @@ export interface SelfHostedCommercialPresentation { footerLinks: readonly SelfHostedLinkCTA[]; } +const getTierMetricHistoryHighlight = (tier: SelfHostedTierKey, metricHistoryDays: number) => + tier === 'relay' || tier === 'pro' ? `${metricHistoryDays}-day metric history` : null; + +const getTierEntitlementHighlights = ( + tier: SelfHostedTierKey, + metricHistoryDays: number, +): readonly string[] => { + const highlights = getSelfHostedFeaturesForRole(tier, 'primary_pillar').map((entry) => + entry.key === 'long_term_metrics' ? `${metricHistoryDays}-day metric history` : entry.comparisonName, + ); + const metricHistoryHighlight = getTierMetricHistoryHighlight(tier, metricHistoryDays); + if (!metricHistoryHighlight || highlights.includes(metricHistoryHighlight)) { + return highlights; + } + return [...highlights, metricHistoryHighlight]; +}; + +const getTierIncludedExtras = (tier: SelfHostedTierKey): readonly string[] => + getSelfHostedFeaturesForRole(tier, 'included_extra').map((entry) => entry.displayName); + +const buildSelfHostedComparisonFeatureRows = (): readonly SelfHostedFeatureRow[] => + SELF_HOSTED_FEATURE_CATALOG.filter((entry) => entry.showInComparisonTable).map((entry) => ({ + key: entry.key, + name: entry.comparisonName, + community: entry.includedIn.community, + relay: entry.includedIn.relay, + pro: entry.includedIn.pro, + })); + export const SELF_HOSTED_PLAN_DEFINITIONS: readonly SelfHostedPlanDefinition[] = [ { tier: 'community', @@ -80,12 +114,7 @@ export const SELF_HOSTED_PLAN_DEFINITIONS: readonly SelfHostedPlanDefinition[] = billingExtrasSummary: 'Remote access, mobile, and push', entitlementSummary: 'Relay is active on this instance. Remote access, mobile, push, and longer history are unlocked right now.', - entitlementHighlights: [ - 'Pulse Relay (Remote Access)', - 'Mobile App Access', - 'Push Notifications', - '14-day metric history', - ], + entitlementHighlights: getTierEntitlementHighlights('relay', 14), includedExtras: [], comparisonSummary: 'Reach this Pulse instance securely from anywhere, check it from mobile, get push notifications, and keep 14 days of history.', @@ -107,18 +136,8 @@ export const SELF_HOSTED_PLAN_DEFINITIONS: readonly SelfHostedPlanDefinition[] = billingExtrasSummary: 'Root-cause analysis, remediation, and admin extras', entitlementSummary: 'Pulse Pro is active on this instance. Root-cause analysis, safe remediation, and 90-day history are unlocked right now.', - entitlementHighlights: [ - 'Pulse Alert Analysis', - 'Patrol Auto-Fix', - '90-day metric history', - ], - includedExtras: [ - 'Advanced SSO (SAML/Multi-Provider)', - 'Role-Based Access Control (RBAC)', - 'Audit Logging', - 'PDF/CSV Reporting', - 'Centralized Agent Profiles', - ], + entitlementHighlights: getTierEntitlementHighlights('pro', 90), + includedExtras: getTierIncludedExtras('pro'), comparisonSummary: 'Move from monitoring into operations with root-cause answers, safe remediation, and 90-day history. Pulse Pro also includes SAML SSO, RBAC, audit logging, reporting, and agent profiles.', highlights: [ @@ -203,95 +222,5 @@ export const SELF_HOSTED_FEATURE_ROWS: readonly SelfHostedFeatureRow[] = [ relay: `${SELF_HOSTED_PLAN_BY_TIER.relay.metricHistoryDays} days`, pro: `${SELF_HOSTED_PLAN_BY_TIER.pro.metricHistoryDays} days`, }, - { - key: 'update_alerts', - name: 'Update Alerts (Container/Package)', - community: true, - relay: true, - pro: true, - }, - { - key: 'sso', - name: 'Basic SSO (OIDC)', - community: true, - relay: true, - pro: true, - }, - { - key: 'relay', - name: 'Remote Access (Relay)', - community: false, - relay: true, - pro: true, - }, - { - key: 'mobile_app', - name: 'Mobile App Access', - community: false, - relay: true, - pro: true, - }, - { - key: 'push_notifications', - name: 'Push Notifications', - community: false, - relay: true, - pro: true, - }, - { - key: 'ai_patrol', - name: 'Pulse Patrol', - community: true, - relay: true, - pro: true, - }, - { - key: 'ai_autofix', - name: 'Patrol Auto-Fix', - community: false, - relay: false, - pro: true, - }, - { - key: 'ai_alerts', - name: 'Pulse Alert Analysis', - community: false, - relay: false, - pro: true, - }, - { - key: 'rbac', - name: 'Role-Based Access Control (RBAC)', - community: false, - relay: false, - pro: true, - }, - { - key: 'audit_logging', - name: 'Audit Logging', - community: false, - relay: false, - pro: true, - }, - { - key: 'advanced_sso', - name: 'Advanced SSO (SAML/Multi-Provider)', - community: false, - relay: false, - pro: true, - }, - { - key: 'agent_profiles', - name: 'Centralized Agent Profiles', - community: false, - relay: false, - pro: true, - }, - { - key: 'advanced_reporting', - name: 'PDF/CSV Reporting', - community: false, - relay: false, - pro: true, - }, + ...buildSelfHostedComparisonFeatureRows(), ] as const; diff --git a/pkg/licensing/features.go b/pkg/licensing/features.go index e5f6da42fa..b6654ab568 100644 --- a/pkg/licensing/features.go +++ b/pkg/licensing/features.go @@ -458,60 +458,8 @@ func GetFeatureMinTierName(feature string) string { // GetFeatureDisplayName returns a human-readable name for a feature. func GetFeatureDisplayName(feature string) string { - switch feature { - case FeatureAIPatrol: - return "Pulse Patrol (Background Health Checks)" - case FeatureAIAlerts: - return "Alert Analysis" - case FeatureAIAutoFix: - return "Pulse Patrol Auto-Fix" - case FeatureKubernetesAI: - return "Kubernetes AI Analysis (Compatibility)" - case FeatureUpdateAlerts: - return "Update Alerts (Container/Package Updates)" - case FeatureRBAC: - return "Role-Based Access Control (RBAC)" - case FeatureMultiUser: - return "Multi-User Mode" - case FeatureWhiteLabel: - return "White-Label Branding" - case FeatureMultiTenant: - return "Multi-Tenant Mode" - case FeatureUnlimited: - return "Unlimited Instances" - case FeatureDemoFixtures: - return "Demo Fixtures (Internal)" - case FeatureAgentProfiles: - return "Centralized Agent Profiles" - case FeatureAuditLogging: - return "Audit Logging" - case FeatureSSO: - return "Basic SSO (OIDC)" - case FeatureAdvancedSSO: - return "Advanced SSO (SAML/Multi-Provider)" - case FeatureRelay: - return "Pulse Relay (Remote Access)" - case FeatureMobileApp: - return "Mobile App Access" - case FeaturePushNotifications: - return "Push Notifications" - case FeatureAdvancedReporting: - return "PDF/CSV Reporting" - case FeatureLongTermMetrics: - return "Extended Metric History" - default: - return feature + if entry, ok := GetFeatureMetadata(feature); ok { + return entry.DisplayName } -} - -var compatibilityOnlyFeatures = map[string]struct{}{ - FeatureKubernetesAI: {}, -} - -// IsCompatibilityOnlyFeature reports capability keys that remain valid runtime -// contracts for backwards compatibility but should not be marketed as current -// v6 commercial pillars or generic upgrade prompts. -func IsCompatibilityOnlyFeature(feature string) bool { - _, ok := compatibilityOnlyFeatures[feature] - return ok + return feature } diff --git a/pkg/licensing/features_test.go b/pkg/licensing/features_test.go index 0a8fd92210..89b0394327 100644 --- a/pkg/licensing/features_test.go +++ b/pkg/licensing/features_test.go @@ -264,6 +264,46 @@ func TestIsCompatibilityOnlyFeature(t *testing.T) { } } +func TestSelfHostedFeatureMetadataKeepsCanonicalPlanLabelsAndVisibility(t *testing.T) { + updateAlerts, ok := GetFeatureMetadata(FeatureUpdateAlerts) + if !ok { + t.Fatalf("expected metadata for %q", FeatureUpdateAlerts) + } + if updateAlerts.DisplayName != "Update Alerts (Container/Package Updates)" { + t.Fatalf("DisplayName = %q, want detailed runtime label", updateAlerts.DisplayName) + } + if updateAlerts.ComparisonName != "Update Alerts" { + t.Fatalf("ComparisonName = %q, want customer-facing label", updateAlerts.ComparisonName) + } + + relay, ok := GetFeatureMetadata(FeatureRelay) + if !ok { + t.Fatalf("expected metadata for %q", FeatureRelay) + } + if relay.ComparisonName != "Pulse Relay (Remote Access)" { + t.Fatalf("ComparisonName = %q, want canonical Relay marketing label", relay.ComparisonName) + } + if GetSelfHostedFeatureRole(FeatureRelay, TierRelay) != SelfHostedFeatureRolePrimaryPillar { + t.Fatalf("expected %q to stay a Relay primary pillar", FeatureRelay) + } + + multiTenant, ok := GetFeatureMetadata(FeatureMultiTenant) + if !ok { + t.Fatalf("expected metadata for %q", FeatureMultiTenant) + } + if !multiTenant.DisplayableInPlanUI { + t.Fatalf("expected %q to remain customer-displayable for MSP/Enterprise surfaces", FeatureMultiTenant) + } + + kubernetes, ok := GetFeatureMetadata(FeatureKubernetesAI) + if !ok { + t.Fatalf("expected metadata for %q", FeatureKubernetesAI) + } + if kubernetes.DisplayableInPlanUI { + t.Fatalf("did not expect compatibility-only %q to be displayable in plan UI", FeatureKubernetesAI) + } +} + func TestTierMonitoredSystemLimits(t *testing.T) { tests := []struct { tier Tier diff --git a/pkg/licensing/self_hosted_feature_catalog.go b/pkg/licensing/self_hosted_feature_catalog.go new file mode 100644 index 0000000000..d831df746b --- /dev/null +++ b/pkg/licensing/self_hosted_feature_catalog.go @@ -0,0 +1,393 @@ +package licensing + +import "sort" + +type SelfHostedFeatureRole string + +const ( + SelfHostedFeatureRoleHidden SelfHostedFeatureRole = "hidden" + SelfHostedFeatureRoleIncluded SelfHostedFeatureRole = "included" + SelfHostedFeatureRolePrimaryPillar SelfHostedFeatureRole = "primary_pillar" + SelfHostedFeatureRoleIncludedExtra SelfHostedFeatureRole = "included_extra" + SelfHostedFeatureRoleCompatibilityOnly SelfHostedFeatureRole = "compatibility_only" +) + +type SelfHostedFeatureRoles struct { + Community SelfHostedFeatureRole + Relay SelfHostedFeatureRole + Pro SelfHostedFeatureRole +} + +type FeatureMetadata struct { + Key string + DisplayName string + ComparisonName string + ShowInComparisonTable bool + DisplayableInPlanUI bool + SelfHostedRoles SelfHostedFeatureRoles + UpgradeReason string + UpgradePriority int +} + +var orderedFeatureMetadataKeys = []string{ + FeatureUpdateAlerts, + FeatureSSO, + FeatureAIPatrol, + FeatureRelay, + FeatureMobileApp, + FeaturePushNotifications, + FeatureAIAlerts, + FeatureAIAutoFix, + FeatureLongTermMetrics, + FeatureAdvancedSSO, + FeatureRBAC, + FeatureAuditLogging, + FeatureAdvancedReporting, + FeatureAgentProfiles, + FeatureKubernetesAI, + FeatureMultiUser, + FeatureWhiteLabel, + FeatureMultiTenant, + FeatureUnlimited, + FeatureDemoFixtures, +} + +var featureMetadataCatalog = map[string]FeatureMetadata{ + FeatureUpdateAlerts: { + Key: FeatureUpdateAlerts, + DisplayName: "Update Alerts (Container/Package Updates)", + ComparisonName: "Update Alerts", + ShowInComparisonTable: true, + DisplayableInPlanUI: true, + SelfHostedRoles: SelfHostedFeatureRoles{ + Community: SelfHostedFeatureRoleIncluded, + Relay: SelfHostedFeatureRoleIncluded, + Pro: SelfHostedFeatureRoleIncluded, + }, + }, + FeatureSSO: { + Key: FeatureSSO, + DisplayName: "Basic SSO (OIDC)", + ComparisonName: "Basic SSO (OIDC)", + ShowInComparisonTable: true, + DisplayableInPlanUI: true, + SelfHostedRoles: SelfHostedFeatureRoles{ + Community: SelfHostedFeatureRoleIncluded, + Relay: SelfHostedFeatureRoleIncluded, + Pro: SelfHostedFeatureRoleIncluded, + }, + }, + FeatureAIPatrol: { + Key: FeatureAIPatrol, + DisplayName: "Pulse Patrol (Background Health Checks)", + ComparisonName: "Pulse Patrol", + ShowInComparisonTable: true, + DisplayableInPlanUI: true, + SelfHostedRoles: SelfHostedFeatureRoles{ + Community: SelfHostedFeatureRoleIncluded, + Relay: SelfHostedFeatureRoleIncluded, + Pro: SelfHostedFeatureRoleIncluded, + }, + }, + FeatureRelay: { + Key: FeatureRelay, + DisplayName: "Pulse Relay (Remote Access)", + ComparisonName: "Pulse Relay (Remote Access)", + ShowInComparisonTable: true, + DisplayableInPlanUI: true, + SelfHostedRoles: SelfHostedFeatureRoles{ + Community: SelfHostedFeatureRoleHidden, + Relay: SelfHostedFeatureRolePrimaryPillar, + Pro: SelfHostedFeatureRoleIncluded, + }, + UpgradeReason: "Get Relay so Pulse stays reachable securely from anywhere instead of only on the local dashboard.", + UpgradePriority: 1, + }, + FeatureMobileApp: { + Key: FeatureMobileApp, + DisplayName: "Mobile App Access", + ComparisonName: "Mobile App Access", + ShowInComparisonTable: true, + DisplayableInPlanUI: true, + SelfHostedRoles: SelfHostedFeatureRoles{ + Community: SelfHostedFeatureRoleHidden, + Relay: SelfHostedFeatureRolePrimaryPillar, + Pro: SelfHostedFeatureRoleIncluded, + }, + UpgradeReason: "Get Relay so you can check Pulse from your phone when you are away from the dashboard.", + UpgradePriority: 1, + }, + FeaturePushNotifications: { + Key: FeaturePushNotifications, + DisplayName: "Push Notifications", + ComparisonName: "Push Notifications", + ShowInComparisonTable: true, + DisplayableInPlanUI: true, + SelfHostedRoles: SelfHostedFeatureRoles{ + Community: SelfHostedFeatureRoleHidden, + Relay: SelfHostedFeatureRolePrimaryPillar, + Pro: SelfHostedFeatureRoleIncluded, + }, + UpgradeReason: "Get Relay so important alerts reach you immediately on mobile instead of waiting for you to reopen Pulse.", + UpgradePriority: 1, + }, + FeatureLongTermMetrics: { + Key: FeatureLongTermMetrics, + DisplayName: "Extended Metric History", + ComparisonName: "Extended Metric History", + ShowInComparisonTable: false, + DisplayableInPlanUI: true, + SelfHostedRoles: SelfHostedFeatureRoles{ + Community: SelfHostedFeatureRoleHidden, + Relay: SelfHostedFeatureRolePrimaryPillar, + Pro: SelfHostedFeatureRolePrimaryPillar, + }, + UpgradeReason: "Get Relay for 14 days of history, or Pro for 90 days, so you can see what changed before and after an incident.", + UpgradePriority: 2, + }, + FeatureAIAlerts: { + Key: FeatureAIAlerts, + DisplayName: "Alert Analysis", + ComparisonName: "Pulse Alert Analysis", + ShowInComparisonTable: true, + DisplayableInPlanUI: true, + SelfHostedRoles: SelfHostedFeatureRoles{ + Community: SelfHostedFeatureRoleHidden, + Relay: SelfHostedFeatureRoleHidden, + Pro: SelfHostedFeatureRolePrimaryPillar, + }, + UpgradeReason: "Upgrade to Pro so alerts arrive with root-cause analysis instead of a stack of symptoms.", + UpgradePriority: 4, + }, + FeatureAIAutoFix: { + Key: FeatureAIAutoFix, + DisplayName: "Pulse Patrol Auto-Fix", + ComparisonName: "Patrol Auto-Fix", + ShowInComparisonTable: true, + DisplayableInPlanUI: true, + SelfHostedRoles: SelfHostedFeatureRoles{ + Community: SelfHostedFeatureRoleHidden, + Relay: SelfHostedFeatureRoleHidden, + Pro: SelfHostedFeatureRolePrimaryPillar, + }, + UpgradeReason: "Upgrade to Pro so Pulse can move from finding issues to applying safe remediation with your approval or in autonomous mode.", + UpgradePriority: 3, + }, + FeatureKubernetesAI: { + Key: FeatureKubernetesAI, + DisplayName: "Kubernetes AI Analysis (Compatibility)", + ComparisonName: "Kubernetes AI Analysis (Compatibility)", + ShowInComparisonTable: false, + DisplayableInPlanUI: false, + SelfHostedRoles: SelfHostedFeatureRoles{ + Community: SelfHostedFeatureRoleHidden, + Relay: SelfHostedFeatureRoleHidden, + Pro: SelfHostedFeatureRoleCompatibilityOnly, + }, + }, + FeatureAgentProfiles: { + Key: FeatureAgentProfiles, + DisplayName: "Centralized Agent Profiles", + ComparisonName: "Centralized Agent Profiles", + ShowInComparisonTable: true, + DisplayableInPlanUI: true, + SelfHostedRoles: SelfHostedFeatureRoles{ + Community: SelfHostedFeatureRoleHidden, + Relay: SelfHostedFeatureRoleHidden, + Pro: SelfHostedFeatureRoleIncludedExtra, + }, + UpgradeReason: "Upgrade to Pro to standardize agent behavior across systems without reconfiguring every install by hand.", + UpgradePriority: 6, + }, + FeatureRBAC: { + Key: FeatureRBAC, + DisplayName: "Role-Based Access Control (RBAC)", + ComparisonName: "Role-Based Access Control (RBAC)", + ShowInComparisonTable: true, + DisplayableInPlanUI: true, + SelfHostedRoles: SelfHostedFeatureRoles{ + Community: SelfHostedFeatureRoleHidden, + Relay: SelfHostedFeatureRoleHidden, + Pro: SelfHostedFeatureRoleIncludedExtra, + }, + UpgradeReason: "Upgrade to Pro when more than one operator needs safe access boundaries around infrastructure changes.", + UpgradePriority: 5, + }, + FeatureAuditLogging: { + Key: FeatureAuditLogging, + DisplayName: "Audit Logging", + ComparisonName: "Audit Logging", + ShowInComparisonTable: true, + DisplayableInPlanUI: true, + SelfHostedRoles: SelfHostedFeatureRoles{ + Community: SelfHostedFeatureRoleHidden, + Relay: SelfHostedFeatureRoleHidden, + Pro: SelfHostedFeatureRoleIncludedExtra, + }, + UpgradeReason: "Upgrade to Pro to keep a trustworthy action trail for incident review, accountability, and compliance.", + UpgradePriority: 8, + }, + FeatureAdvancedSSO: { + Key: FeatureAdvancedSSO, + DisplayName: "Advanced SSO (SAML/Multi-Provider)", + ComparisonName: "Advanced SSO (SAML/Multi-Provider)", + ShowInComparisonTable: true, + DisplayableInPlanUI: true, + SelfHostedRoles: SelfHostedFeatureRoles{ + Community: SelfHostedFeatureRoleHidden, + Relay: SelfHostedFeatureRoleHidden, + Pro: SelfHostedFeatureRoleIncludedExtra, + }, + UpgradeReason: "Upgrade to Pro to connect your identity provider and keep operator access aligned with your existing auth controls.", + UpgradePriority: 7, + }, + FeatureAdvancedReporting: { + Key: FeatureAdvancedReporting, + DisplayName: "PDF/CSV Reporting", + ComparisonName: "PDF/CSV Reporting", + ShowInComparisonTable: true, + DisplayableInPlanUI: true, + SelfHostedRoles: SelfHostedFeatureRoles{ + Community: SelfHostedFeatureRoleHidden, + Relay: SelfHostedFeatureRoleHidden, + Pro: SelfHostedFeatureRoleIncludedExtra, + }, + UpgradeReason: "Upgrade to Pro to turn live infrastructure state into shareable reports without manual screenshot work.", + UpgradePriority: 9, + }, + FeatureMultiUser: { + Key: FeatureMultiUser, + DisplayName: "Multi-User Mode", + ComparisonName: "Multi-User Mode", + DisplayableInPlanUI: false, + SelfHostedRoles: SelfHostedFeatureRoles{ + Community: SelfHostedFeatureRoleHidden, + Relay: SelfHostedFeatureRoleHidden, + Pro: SelfHostedFeatureRoleHidden, + }, + }, + FeatureWhiteLabel: { + Key: FeatureWhiteLabel, + DisplayName: "White-Label Branding", + ComparisonName: "White-Label Branding", + DisplayableInPlanUI: false, + SelfHostedRoles: SelfHostedFeatureRoles{ + Community: SelfHostedFeatureRoleHidden, + Relay: SelfHostedFeatureRoleHidden, + Pro: SelfHostedFeatureRoleHidden, + }, + }, + FeatureMultiTenant: { + Key: FeatureMultiTenant, + DisplayName: "Multi-Tenant Mode", + ComparisonName: "Multi-Tenant Mode", + DisplayableInPlanUI: true, + SelfHostedRoles: SelfHostedFeatureRoles{ + Community: SelfHostedFeatureRoleHidden, + Relay: SelfHostedFeatureRoleHidden, + Pro: SelfHostedFeatureRoleHidden, + }, + }, + FeatureUnlimited: { + Key: FeatureUnlimited, + DisplayName: "Unlimited Instances", + ComparisonName: "Unlimited Instances", + DisplayableInPlanUI: false, + SelfHostedRoles: SelfHostedFeatureRoles{ + Community: SelfHostedFeatureRoleHidden, + Relay: SelfHostedFeatureRoleHidden, + Pro: SelfHostedFeatureRoleHidden, + }, + }, + FeatureDemoFixtures: { + Key: FeatureDemoFixtures, + DisplayName: "Demo Fixtures (Internal)", + ComparisonName: "Demo Fixtures (Internal)", + DisplayableInPlanUI: false, + SelfHostedRoles: SelfHostedFeatureRoles{ + Community: SelfHostedFeatureRoleHidden, + Relay: SelfHostedFeatureRoleHidden, + Pro: SelfHostedFeatureRoleHidden, + }, + }, +} + +func AllFeatureMetadata() []FeatureMetadata { + metadata := make([]FeatureMetadata, 0, len(orderedFeatureMetadataKeys)) + for _, key := range orderedFeatureMetadataKeys { + entry, ok := featureMetadataCatalog[key] + if !ok { + continue + } + metadata = append(metadata, entry) + } + return metadata +} + +func GetFeatureMetadata(feature string) (FeatureMetadata, bool) { + entry, ok := featureMetadataCatalog[feature] + return entry, ok +} + +func GetSelfHostedFeatureRole(feature string, tier Tier) SelfHostedFeatureRole { + entry, ok := featureMetadataCatalog[feature] + if !ok { + return SelfHostedFeatureRoleHidden + } + switch tier { + case TierFree: + return entry.SelfHostedRoles.Community + case TierRelay: + return entry.SelfHostedRoles.Relay + case TierPro, TierProPlus, TierProAnnual, TierLifetime: + return entry.SelfHostedRoles.Pro + default: + return SelfHostedFeatureRoleHidden + } +} + +func IsCompatibilityOnlyFeature(feature string) bool { + entry, ok := featureMetadataCatalog[feature] + if !ok { + return false + } + return entry.SelfHostedRoles.Community == SelfHostedFeatureRoleCompatibilityOnly || + entry.SelfHostedRoles.Relay == SelfHostedFeatureRoleCompatibilityOnly || + entry.SelfHostedRoles.Pro == SelfHostedFeatureRoleCompatibilityOnly +} + +func SelfHostedComparisonFeatures() []FeatureMetadata { + out := make([]FeatureMetadata, 0) + for _, entry := range AllFeatureMetadata() { + if entry.ShowInComparisonTable { + out = append(out, entry) + } + } + return out +} + +func SelfHostedPlanFeaturesForRole(tier Tier, role SelfHostedFeatureRole) []FeatureMetadata { + out := make([]FeatureMetadata, 0) + for _, entry := range AllFeatureMetadata() { + if GetSelfHostedFeatureRole(entry.Key, tier) == role { + out = append(out, entry) + } + } + return out +} + +func GenericUpgradeFeatureMetadata() []FeatureMetadata { + out := make([]FeatureMetadata, 0) + for _, entry := range AllFeatureMetadata() { + if entry.UpgradeReason != "" { + out = append(out, entry) + } + } + sort.SliceStable(out, func(i, j int) bool { + if out[i].UpgradePriority == out[j].UpgradePriority { + return out[i].Key < out[j].Key + } + return out[i].UpgradePriority < out[j].UpgradePriority + }) + return out +} diff --git a/pkg/licensing/upgrade_reasons.go b/pkg/licensing/upgrade_reasons.go index 160604060f..49a911543a 100644 --- a/pkg/licensing/upgrade_reasons.go +++ b/pkg/licensing/upgrade_reasons.go @@ -14,73 +14,20 @@ type ReasonEntry struct { // Relay features use "Upgrade to Relay" messaging; Pro features use "Upgrade to Pro". // Compatibility-only capabilities must not appear here as generic marketed // upgrade reasons. -var UpgradeReasonMatrix = []ReasonEntry{ - { - Feature: FeatureRelay, - Reason: "Get Relay so Pulse stays reachable securely from anywhere instead of only on the local dashboard.", - ActionURL: UpgradeURLForFeature(FeatureRelay), - Priority: 1, - }, - { - Feature: FeatureMobileApp, - Reason: "Get Relay so you can check Pulse from your phone when you are away from the dashboard.", - ActionURL: UpgradeURLForFeature(FeatureMobileApp), - Priority: 1, - }, - { - Feature: FeaturePushNotifications, - Reason: "Get Relay so important alerts reach you immediately on mobile instead of waiting for you to reopen Pulse.", - ActionURL: UpgradeURLForFeature(FeaturePushNotifications), - Priority: 1, - }, - { - Feature: FeatureLongTermMetrics, - Reason: "Get Relay for 14 days of history, or Pro for 90 days, so you can see what changed before and after an incident.", - ActionURL: UpgradeURLForFeature(FeatureLongTermMetrics), - Priority: 2, - }, - { - Feature: FeatureAIAutoFix, - Reason: "Upgrade to Pro so Pulse can move from finding issues to applying safe remediation with your approval or in autonomous mode.", - ActionURL: UpgradeURLForFeature(FeatureAIAutoFix), - Priority: 3, - }, - { - Feature: FeatureAIAlerts, - Reason: "Upgrade to Pro so alerts arrive with root-cause analysis instead of a stack of symptoms.", - ActionURL: UpgradeURLForFeature(FeatureAIAlerts), - Priority: 4, - }, - { - Feature: FeatureRBAC, - Reason: "Upgrade to Pro when more than one operator needs safe access boundaries around infrastructure changes.", - ActionURL: UpgradeURLForFeature(FeatureRBAC), - Priority: 5, - }, - { - Feature: FeatureAgentProfiles, - Reason: "Upgrade to Pro to standardize agent behavior across systems without reconfiguring every install by hand.", - ActionURL: UpgradeURLForFeature(FeatureAgentProfiles), - Priority: 6, - }, - { - Feature: FeatureAdvancedSSO, - Reason: "Upgrade to Pro to connect your identity provider and keep operator access aligned with your existing auth controls.", - ActionURL: UpgradeURLForFeature(FeatureAdvancedSSO), - Priority: 7, - }, - { - Feature: FeatureAuditLogging, - Reason: "Upgrade to Pro to keep a trustworthy action trail for incident review, accountability, and compliance.", - ActionURL: UpgradeURLForFeature(FeatureAuditLogging), - Priority: 8, - }, - { - Feature: FeatureAdvancedReporting, - Reason: "Upgrade to Pro to turn live infrastructure state into shareable reports without manual screenshot work.", - ActionURL: UpgradeURLForFeature(FeatureAdvancedReporting), - Priority: 9, - }, +var UpgradeReasonMatrix = buildUpgradeReasonMatrix() + +func buildUpgradeReasonMatrix() []ReasonEntry { + metadata := GenericUpgradeFeatureMetadata() + reasons := make([]ReasonEntry, 0, len(metadata)) + for _, entry := range metadata { + reasons = append(reasons, ReasonEntry{ + Feature: entry.Key, + Reason: entry.UpgradeReason, + ActionURL: UpgradeURLForFeature(entry.Key), + Priority: entry.UpgradePriority, + }) + } + return reasons } // GenerateUpgradeReasons returns upgrade reasons for features missing in capabilities. diff --git a/pkg/licensing/upgrade_test.go b/pkg/licensing/upgrade_test.go index dbe54788d8..5b4cd0bd1e 100644 --- a/pkg/licensing/upgrade_test.go +++ b/pkg/licensing/upgrade_test.go @@ -109,3 +109,29 @@ func TestCompatibilityOnlyFeaturesStayOutOfGenericUpgradeReasons(t *testing.T) { } } } + +func TestUpgradeReasonMatrixDerivesFromCanonicalFeatureMetadata(t *testing.T) { + expectedOrder := []string{ + FeatureMobileApp, + FeaturePushNotifications, + FeatureRelay, + FeatureLongTermMetrics, + FeatureAIAutoFix, + FeatureAIAlerts, + FeatureRBAC, + FeatureAgentProfiles, + FeatureAdvancedSSO, + FeatureAuditLogging, + FeatureAdvancedReporting, + } + + if len(UpgradeReasonMatrix) != len(expectedOrder) { + t.Fatalf("UpgradeReasonMatrix length = %d, want %d", len(UpgradeReasonMatrix), len(expectedOrder)) + } + + for idx, feature := range expectedOrder { + if UpgradeReasonMatrix[idx].Feature != feature { + t.Fatalf("UpgradeReasonMatrix[%d].Feature = %q, want %q", idx, UpgradeReasonMatrix[idx].Feature, feature) + } + } +} diff --git a/scripts/generate-self-hosted-feature-catalog.go b/scripts/generate-self-hosted-feature-catalog.go new file mode 100644 index 0000000000..2fee503bca --- /dev/null +++ b/scripts/generate-self-hosted-feature-catalog.go @@ -0,0 +1,269 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "go/format" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/rcourtman/pulse-go-rewrite/pkg/licensing" +) + +type generatedFeatureEntry struct { + Key string `json:"key"` + DisplayName string `json:"displayName"` + ComparisonName string `json:"comparisonName"` + ShowInComparisonTable bool `json:"showInComparisonTable"` + DisplayableInSelfHostedPlan bool `json:"displayableInSelfHostedPlan"` + Roles generatedFeatureRoles `json:"roles"` + IncludedIn generatedFeatureTierInclusion `json:"includedIn"` +} + +type generatedFeatureRoles struct { + Community licensing.SelfHostedFeatureRole `json:"community"` + Relay licensing.SelfHostedFeatureRole `json:"relay"` + Pro licensing.SelfHostedFeatureRole `json:"pro"` +} + +type generatedFeatureTierInclusion struct { + Community bool `json:"community"` + Relay bool `json:"relay"` + Pro bool `json:"pro"` +} + +func main() { + repoRoot, err := os.Getwd() + if err != nil { + fail(err) + } + + entries := buildGeneratedEntries() + tierFeatures := map[string][]string{ + "community": append([]string(nil), licensing.TierFeatures[licensing.TierFree]...), + "relay": append([]string(nil), licensing.TierFeatures[licensing.TierRelay]...), + "pro": append([]string(nil), licensing.TierFeatures[licensing.TierPro]...), + } + + if err := writeFrontendModule(filepath.Join(repoRoot, "frontend-modern/src/utils/selfHostedFeatureCatalog.generated.ts"), entries); err != nil { + fail(err) + } + + pulseProRoot := filepath.Clean(filepath.Join(repoRoot, "..", "pulse-pro")) + if _, err := os.Stat(pulseProRoot); err == nil { + if err := writePulseProModule(filepath.Join(pulseProRoot, "license-server/self_hosted_feature_catalog.generated.go"), entries, tierFeatures); err != nil { + fail(err) + } + } +} + +func buildGeneratedEntries() []generatedFeatureEntry { + metadata := licensing.AllFeatureMetadata() + entries := make([]generatedFeatureEntry, 0, len(metadata)) + for _, entry := range metadata { + roles := generatedFeatureRoles{ + Community: entry.SelfHostedRoles.Community, + Relay: entry.SelfHostedRoles.Relay, + Pro: entry.SelfHostedRoles.Pro, + } + included := generatedFeatureTierInclusion{ + Community: licensing.TierHasFeature(licensing.TierFree, entry.Key), + Relay: licensing.TierHasFeature(licensing.TierRelay, entry.Key), + Pro: licensing.TierHasFeature(licensing.TierPro, entry.Key), + } + entries = append(entries, generatedFeatureEntry{ + Key: entry.Key, + DisplayName: entry.DisplayName, + ComparisonName: entry.ComparisonName, + ShowInComparisonTable: entry.ShowInComparisonTable, + DisplayableInSelfHostedPlan: entry.DisplayableInPlanUI, + Roles: roles, + IncludedIn: included, + }) + } + return entries +} + +func writeFrontendModule(path string, entries []generatedFeatureEntry) error { + payload, err := json.MarshalIndent(entries, "", " ") + if err != nil { + return err + } + + var buf bytes.Buffer + buf.WriteString("// This file is generated by scripts/generate-self-hosted-feature-catalog.go.\n") + buf.WriteString("// Do not edit it manually.\n\n") + buf.WriteString("export type SelfHostedFeatureRole =\n") + buf.WriteString(" | 'hidden'\n") + buf.WriteString(" | 'included'\n") + buf.WriteString(" | 'primary_pillar'\n") + buf.WriteString(" | 'included_extra'\n") + buf.WriteString(" | 'compatibility_only';\n\n") + buf.WriteString("export interface GeneratedSelfHostedFeatureCatalogEntry {\n") + buf.WriteString(" key: string;\n") + buf.WriteString(" displayName: string;\n") + buf.WriteString(" comparisonName: string;\n") + buf.WriteString(" showInComparisonTable: boolean;\n") + buf.WriteString(" displayableInSelfHostedPlan: boolean;\n") + buf.WriteString(" roles: {\n") + buf.WriteString(" community: SelfHostedFeatureRole;\n") + buf.WriteString(" relay: SelfHostedFeatureRole;\n") + buf.WriteString(" pro: SelfHostedFeatureRole;\n") + buf.WriteString(" };\n") + buf.WriteString(" includedIn: {\n") + buf.WriteString(" community: boolean;\n") + buf.WriteString(" relay: boolean;\n") + buf.WriteString(" pro: boolean;\n") + buf.WriteString(" };\n") + buf.WriteString("}\n\n") + buf.WriteString("export const SELF_HOSTED_FEATURE_CATALOG: readonly GeneratedSelfHostedFeatureCatalogEntry[] = ") + buf.Write(payload) + buf.WriteString(" as const;\n\n") + buf.WriteString("export const SELF_HOSTED_FEATURE_CATALOG_BY_KEY: Record = Object.fromEntries(\n") + buf.WriteString(" SELF_HOSTED_FEATURE_CATALOG.map((entry) => [entry.key, entry]),\n") + buf.WriteString(") as Record;\n\n") + buf.WriteString("export function getSelfHostedFeatureCatalogEntry(\n") + buf.WriteString(" key?: string | null,\n") + buf.WriteString("): GeneratedSelfHostedFeatureCatalogEntry | null {\n") + buf.WriteString(" const normalized = (key || '').trim();\n") + buf.WriteString(" if (!normalized) {\n") + buf.WriteString(" return null;\n") + buf.WriteString(" }\n") + buf.WriteString(" return SELF_HOSTED_FEATURE_CATALOG_BY_KEY[normalized] || null;\n") + buf.WriteString("}\n\n") + buf.WriteString("export function isDisplayableSelfHostedFeatureKey(key?: string | null): boolean {\n") + buf.WriteString(" const entry = getSelfHostedFeatureCatalogEntry(key);\n") + buf.WriteString(" if (!entry) {\n") + buf.WriteString(" return true;\n") + buf.WriteString(" }\n") + buf.WriteString(" return entry.displayableInSelfHostedPlan;\n") + buf.WriteString("}\n\n") + buf.WriteString("export function getSelfHostedFeaturesForRole(\n") + buf.WriteString(" tier: 'community' | 'relay' | 'pro',\n") + buf.WriteString(" role: SelfHostedFeatureRole,\n") + buf.WriteString("): readonly GeneratedSelfHostedFeatureCatalogEntry[] {\n") + buf.WriteString(" return SELF_HOSTED_FEATURE_CATALOG.filter((entry) => entry.roles[tier] === role);\n") + buf.WriteString("}\n") + + return os.WriteFile(path, buf.Bytes(), 0o644) +} + +func writePulseProModule(path string, entries []generatedFeatureEntry, tierFeatures map[string][]string) error { + var buf bytes.Buffer + buf.WriteString("// Code generated by ../pulse/scripts/generate-self-hosted-feature-catalog.go; DO NOT EDIT.\n") + buf.WriteString("package main\n\n") + buf.WriteString("import \"strings\"\n\n") + buf.WriteString("type selfHostedFeatureRole string\n\n") + buf.WriteString("const (\n") + buf.WriteString("\tselfHostedFeatureRoleHidden selfHostedFeatureRole = \"hidden\"\n") + buf.WriteString("\tselfHostedFeatureRoleIncluded selfHostedFeatureRole = \"included\"\n") + buf.WriteString("\tselfHostedFeatureRolePrimaryPillar selfHostedFeatureRole = \"primary_pillar\"\n") + buf.WriteString("\tselfHostedFeatureRoleIncludedExtra selfHostedFeatureRole = \"included_extra\"\n") + buf.WriteString("\tselfHostedFeatureRoleCompatibilityOnly selfHostedFeatureRole = \"compatibility_only\"\n") + buf.WriteString(")\n\n") + buf.WriteString("type selfHostedFeatureCatalogEntry struct {\n") + buf.WriteString("\tKey string\n") + buf.WriteString("\tDisplayName string\n") + buf.WriteString("\tComparisonName string\n") + buf.WriteString("\tShowInComparisonTable bool\n") + buf.WriteString("\tDisplayableInSelfHostedPlan bool\n") + buf.WriteString("\tCommunityRole selfHostedFeatureRole\n") + buf.WriteString("\tRelayRole selfHostedFeatureRole\n") + buf.WriteString("\tProRole selfHostedFeatureRole\n") + buf.WriteString("\tCommunity bool\n") + buf.WriteString("\tRelay bool\n") + buf.WriteString("\tPro bool\n") + buf.WriteString("}\n\n") + + buf.WriteString("var selfHostedFeatureCatalog = []selfHostedFeatureCatalogEntry{\n") + for _, entry := range entries { + buf.WriteString("\t{\n") + buf.WriteString(fmt.Sprintf("\t\tKey: %q,\n", entry.Key)) + buf.WriteString(fmt.Sprintf("\t\tDisplayName: %q,\n", entry.DisplayName)) + buf.WriteString(fmt.Sprintf("\t\tComparisonName: %q,\n", entry.ComparisonName)) + buf.WriteString(fmt.Sprintf("\t\tShowInComparisonTable: %t,\n", entry.ShowInComparisonTable)) + buf.WriteString(fmt.Sprintf("\t\tDisplayableInSelfHostedPlan: %t,\n", entry.DisplayableInSelfHostedPlan)) + buf.WriteString(fmt.Sprintf("\t\tCommunityRole: %q,\n", entry.Roles.Community)) + buf.WriteString(fmt.Sprintf("\t\tRelayRole: %q,\n", entry.Roles.Relay)) + buf.WriteString(fmt.Sprintf("\t\tProRole: %q,\n", entry.Roles.Pro)) + buf.WriteString(fmt.Sprintf("\t\tCommunity: %t,\n", entry.IncludedIn.Community)) + buf.WriteString(fmt.Sprintf("\t\tRelay: %t,\n", entry.IncludedIn.Relay)) + buf.WriteString(fmt.Sprintf("\t\tPro: %t,\n", entry.IncludedIn.Pro)) + buf.WriteString("\t},\n") + } + buf.WriteString("}\n\n") + + buf.WriteString("var selfHostedFeatureCatalogByKey = map[string]selfHostedFeatureCatalogEntry{\n") + for _, entry := range entries { + buf.WriteString(fmt.Sprintf("\t%q: {\n", entry.Key)) + buf.WriteString(fmt.Sprintf("\t\tKey: %q,\n", entry.Key)) + buf.WriteString(fmt.Sprintf("\t\tDisplayName: %q,\n", entry.DisplayName)) + buf.WriteString(fmt.Sprintf("\t\tComparisonName: %q,\n", entry.ComparisonName)) + buf.WriteString(fmt.Sprintf("\t\tShowInComparisonTable: %t,\n", entry.ShowInComparisonTable)) + buf.WriteString(fmt.Sprintf("\t\tDisplayableInSelfHostedPlan: %t,\n", entry.DisplayableInSelfHostedPlan)) + buf.WriteString(fmt.Sprintf("\t\tCommunityRole: %q,\n", entry.Roles.Community)) + buf.WriteString(fmt.Sprintf("\t\tRelayRole: %q,\n", entry.Roles.Relay)) + buf.WriteString(fmt.Sprintf("\t\tProRole: %q,\n", entry.Roles.Pro)) + buf.WriteString(fmt.Sprintf("\t\tCommunity: %t,\n", entry.IncludedIn.Community)) + buf.WriteString(fmt.Sprintf("\t\tRelay: %t,\n", entry.IncludedIn.Relay)) + buf.WriteString(fmt.Sprintf("\t\tPro: %t,\n", entry.IncludedIn.Pro)) + buf.WriteString("\t},\n") + } + buf.WriteString("}\n\n") + + buf.WriteString("var canonicalSelfHostedTierFeatures = map[string][]string{\n") + tiers := make([]string, 0, len(tierFeatures)) + for tier := range tierFeatures { + tiers = append(tiers, tier) + } + sort.Strings(tiers) + for _, tier := range tiers { + features := append([]string(nil), tierFeatures[tier]...) + sort.Strings(features) + buf.WriteString(fmt.Sprintf("\t%q: []string{%s},\n", tier, quoteJoin(features))) + } + buf.WriteString("}\n\n") + + buf.WriteString("func selfHostedFeaturesForRole(tier string, role selfHostedFeatureRole) []selfHostedFeatureCatalogEntry {\n") + buf.WriteString("\tentries := make([]selfHostedFeatureCatalogEntry, 0)\n") + buf.WriteString("\tfor _, entry := range selfHostedFeatureCatalog {\n") + buf.WriteString("\t\tvar current selfHostedFeatureRole\n") + buf.WriteString("\t\tswitch strings.ToLower(strings.TrimSpace(tier)) {\n") + buf.WriteString("\t\tcase \"community\":\n") + buf.WriteString("\t\t\tcurrent = entry.CommunityRole\n") + buf.WriteString("\t\tcase \"relay\":\n") + buf.WriteString("\t\t\tcurrent = entry.RelayRole\n") + buf.WriteString("\t\tcase \"pro\":\n") + buf.WriteString("\t\t\tcurrent = entry.ProRole\n") + buf.WriteString("\t\tdefault:\n") + buf.WriteString("\t\t\tcurrent = selfHostedFeatureRoleHidden\n") + buf.WriteString("\t\t}\n") + buf.WriteString("\t\tif current == role {\n") + buf.WriteString("\t\t\tentries = append(entries, entry)\n") + buf.WriteString("\t\t}\n") + buf.WriteString("\t}\n") + buf.WriteString("\treturn entries\n") + buf.WriteString("}\n") + + formatted, err := format.Source(buf.Bytes()) + if err != nil { + return fmt.Errorf("format pulse-pro module: %w", err) + } + return os.WriteFile(path, formatted, 0o644) +} + +func quoteJoin(values []string) string { + quoted := make([]string, 0, len(values)) + for _, value := range values { + quoted = append(quoted, fmt.Sprintf("%q", value)) + } + return strings.Join(quoted, ", ") +} + +func fail(err error) { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) +}