Encode canonical self-hosted feature metadata

This commit is contained in:
rcourtman 2026-04-23 15:30:42 +01:00
parent 8b6a429186
commit b4692ef7f0
13 changed files with 1244 additions and 262 deletions

View file

@ -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

View file

@ -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",

View file

@ -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');
});

View file

@ -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', () => {

View file

@ -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<string, string> = {
@ -33,28 +37,6 @@ const SELF_HOSTED_PLAN_LABELS: Record<string, string> = {
pro: 'Pulse Pro',
};
const FEATURE_LABELS: Record<string, string> = {
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<string, string> = {
relay: 'Relay',
mobile_app: 'Relay',
@ -62,13 +44,6 @@ const FEATURE_MIN_TIER_LABELS: Record<string, string> = {
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 => {

View file

@ -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<string, GeneratedSelfHostedFeatureCatalogEntry> = Object.fromEntries(
SELF_HOSTED_FEATURE_CATALOG.map((entry) => [entry.key, entry]),
) as Record<string, GeneratedSelfHostedFeatureCatalogEntry>;
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);
}

View file

@ -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;

View file

@ -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
}

View file

@ -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

View file

@ -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
}

View file

@ -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.

View file

@ -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)
}
}
}

View file

@ -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<string, GeneratedSelfHostedFeatureCatalogEntry> = Object.fromEntries(\n")
buf.WriteString(" SELF_HOSTED_FEATURE_CATALOG.map((entry) => [entry.key, entry]),\n")
buf.WriteString(") as Record<string, GeneratedSelfHostedFeatureCatalogEntry>;\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)
}