feat(plan): rename Enterprise plan to Pro; Pro includes Bootstrap by default and Unrestricted Mode on request

- FeatureEntitlementCatalog: TenantEdition.Pro, canonical tier "pro", legacy
  stored "enterprise" stays readable as Pro (no data migration); new
  entitlements BootstrapIncluded + UnrestrictedModeAvailable
- TenantEntitlementService: IsBootstrapEnabled (Pro-included OR additive GA
  flag) and IsUnrestrictedModeActive (Pro AND GA gate AND tenant toggle,
  read-time - trial expiry/downgrade re-arms guardrails fail-closed)
- All bootstrap endpoint gates + SecurityValidator use the effective check;
  /auth/me and feature-flags expose effective availability values
- PlanManagement accepts community|pro; AlreadyPro; Pro trial wording
- Web: edition.ts accepts legacy "enterprise" (deploy-order safety), Pro
  labels/cards + new Pro feature bullets, effective section gating,
  terms/privacy renamed with Last-updated bump
- Tests: catalog matrix, effective-gate payload tests, helper tests incl.
  trial-expiry re-gate (backend 3692 green, web 612 green)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-07-31 13:04:47 +02:00
parent e62bac9fea
commit a70c5de667
56 changed files with 482 additions and 241 deletions
@@ -44,7 +44,7 @@ public class AuthFunctionSideEffectTests
var delegatedAdminMock = new Mock<DelegatedAdminService>(
adminRepo,
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Enterprise),
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Pro),
cache, Mock.Of<ILogger<DelegatedAdminService>>())
{ CallBase = false };
delegatedAdminMock.Setup(x => x.GetScopeAsync(It.IsAny<string>(), It.IsAny<string?>()))
@@ -44,7 +44,7 @@ public class DelegatedAdminEditionGateTests
return new DelegatedAdminService(
repo.Object,
new StubTenantEntitlementService(
tenantId => tenantId == EnterpriseHomeTenant ? TenantEdition.Enterprise : TenantEdition.Community),
tenantId => tenantId == EnterpriseHomeTenant ? TenantEdition.Pro : TenantEdition.Community),
new MemoryCache(new MemoryCacheOptions()),
NullLogger<DelegatedAdminService>.Instance);
}
@@ -118,7 +118,7 @@ public class DelegatedAdminEditionGateTests
var svc = new DelegatedAdminService(
repo.Object,
new StubTenantEntitlementService(
tenantId => tenantId == EnterpriseHomeTenant ? TenantEdition.Enterprise : TenantEdition.Community),
tenantId => tenantId == EnterpriseHomeTenant ? TenantEdition.Pro : TenantEdition.Community),
new MemoryCache(new MemoryCacheOptions()),
NullLogger<DelegatedAdminService>.Instance);
@@ -36,7 +36,7 @@ public class DelegatedAdminServiceTests
// the edition filter has its own dedicated tests (DelegatedAdminEditionGateTests).
var svc = new DelegatedAdminService(
repo.Object,
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Enterprise),
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Pro),
cache,
NullLogger<DelegatedAdminService>.Instance);
return (svc, repo);
@@ -6,8 +6,9 @@ namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Pins the edition-resolution matrix and the per-edition entitlement values of
/// <see cref="FeatureEntitlementCatalog"/>. Fail-closed contract: ONLY the exact tier
/// "enterprise" (or an active trial) yields Enterprise — legacy stored tiers ("free", "pro"),
/// null/empty and unknown values all resolve to Community without any data migration.
/// "pro" / the legacy stored value "enterprise" (or an active trial) yields Pro — the legacy
/// stored tier "free", null/empty and unknown values all resolve to Community without any
/// data migration.
/// </summary>
public class FeatureEntitlementCatalogTests
{
@@ -16,31 +17,34 @@ public class FeatureEntitlementCatalogTests
// ── ResolveEdition matrix ────────────────────────────────────────────────────
[Theory]
[InlineData("enterprise")]
[InlineData("pro")]
[InlineData("Pro")]
[InlineData("PRO")]
[InlineData(" pro ")]
[InlineData("enterprise")] // legacy stored value — must stay readable as Pro
[InlineData("Enterprise")]
[InlineData("ENTERPRISE")]
[InlineData(" enterprise ")]
public void ResolveEdition_EnterpriseTier_IsEnterprise(string tier)
public void ResolveEdition_ProOrLegacyEnterpriseTier_IsPro(string tier)
{
Assert.Equal(TenantEdition.Enterprise, FeatureEntitlementCatalog.ResolveEdition(tier, null, Now));
Assert.Equal(TenantEdition.Pro, FeatureEntitlementCatalog.ResolveEdition(tier, null, Now));
}
[Theory]
[InlineData("free")]
[InlineData("pro")]
[InlineData("community")]
[InlineData("")]
[InlineData(null)]
[InlineData("premium")] // unknown value → fail-closed
public void ResolveEdition_NonEnterpriseTier_IsCommunity(string? tier)
public void ResolveEdition_NonProTier_IsCommunity(string? tier)
{
Assert.Equal(TenantEdition.Community, FeatureEntitlementCatalog.ResolveEdition(tier, null, Now));
}
[Fact]
public void ResolveEdition_ActiveTrial_IsEnterprise_EvenOnFreeTier()
public void ResolveEdition_ActiveTrial_IsPro_EvenOnFreeTier()
{
Assert.Equal(TenantEdition.Enterprise,
Assert.Equal(TenantEdition.Pro,
FeatureEntitlementCatalog.ResolveEdition("free", Now.AddSeconds(1), Now));
}
@@ -59,11 +63,28 @@ public class FeatureEntitlementCatalogTests
FeatureEntitlementCatalog.ResolveEdition(null, Now.AddDays(-1), Now));
}
[Fact]
public void ResolveEdition_ExpiredTrial_ButEnterpriseTier_StaysEnterprise()
[Theory]
[InlineData("pro")]
[InlineData("enterprise")]
public void ResolveEdition_ExpiredTrial_ButPermanentProTier_StaysPro(string tier)
{
Assert.Equal(TenantEdition.Enterprise,
FeatureEntitlementCatalog.ResolveEdition("enterprise", Now.AddDays(-1), Now));
Assert.Equal(TenantEdition.Pro,
FeatureEntitlementCatalog.ResolveEdition(tier, Now.AddDays(-1), Now));
}
// ── IsPermanentProTier ───────────────────────────────────────────────────────
[Theory]
[InlineData("pro", true)]
[InlineData(" PRO ", true)]
[InlineData("enterprise", true)] // legacy stored value
[InlineData("free", false)]
[InlineData("community", false)]
[InlineData("", false)]
[InlineData(null, false)]
public void IsPermanentProTier_Matrix(string? tier, bool expected)
{
Assert.Equal(expected, FeatureEntitlementCatalog.IsPermanentProTier(tier));
}
// ── Entitlement values ───────────────────────────────────────────────────────
@@ -76,20 +97,24 @@ public class FeatureEntitlementCatalogTests
Assert.Null(e.UserRateLimitPerMinute);
Assert.Null(e.DeviceRateLimitPerMinute);
Assert.False(e.DelegatedAdminAllowed);
Assert.False(e.BootstrapIncluded);
Assert.False(e.UnrestrictedModeAvailable);
Assert.Equal("community", e.McpUsagePlanName);
Assert.Equal(100, e.McpDailyRequestLimit);
Assert.Equal(3000, e.McpMonthlyRequestLimit);
}
[Fact]
public void Enterprise_Entitlements_MatchMatrix()
public void Pro_Entitlements_MatchMatrix()
{
var e = FeatureEntitlementCatalog.Get(TenantEdition.Enterprise);
var e = FeatureEntitlementCatalog.Get(TenantEdition.Pro);
Assert.Equal(365, e.RetentionCapDays);
Assert.Equal(150, e.UserRateLimitPerMinute);
Assert.Equal(150, e.DeviceRateLimitPerMinute);
Assert.True(e.DelegatedAdminAllowed);
Assert.Equal("enterprise", e.McpUsagePlanName);
Assert.True(e.BootstrapIncluded);
Assert.True(e.UnrestrictedModeAvailable);
Assert.Equal("pro", e.McpUsagePlanName);
Assert.Equal(1000, e.McpDailyRequestLimit);
Assert.Equal(20000, e.McpMonthlyRequestLimit);
}
@@ -54,11 +54,13 @@ public class GetTenantFeatureFlagsPayloadTests
{
var config = new TenantConfiguration
{
PlanTier = "pro", // unrestrictedMode is EFFECTIVE — requires Pro + gate + toggle
BootstrapTokenEnabled = true,
ValidateAutopilotDevice = true,
ShowScriptOutput = false,
EnableSoftwareInventoryAnalyzer = true,
EnableIntegrityBypassAnalyzer = false,
UnrestrictedModeEnabled = true,
UnrestrictedMode = true,
};
@@ -72,6 +74,55 @@ public class GetTenantFeatureFlagsPayloadTests
Assert.True(element.GetProperty("unrestrictedMode").GetBoolean());
}
// ── Effective feature gates (plan-derived) ──────────────────────────────
[Fact]
public void Payload_Bootstrap_IncludedInPro_WithoutGaFlag()
{
var element = Serialize(new TenantConfiguration { PlanTier = "pro", BootstrapTokenEnabled = false });
Assert.True(element.GetProperty("bootstrapTokenEnabled").GetBoolean());
}
[Fact]
public void Payload_Bootstrap_GaFlagStillWorksForCommunity()
{
var element = Serialize(new TenantConfiguration { PlanTier = "free", BootstrapTokenEnabled = true });
Assert.True(element.GetProperty("bootstrapTokenEnabled").GetBoolean());
}
[Fact]
public void Payload_Bootstrap_CommunityWithoutFlag_Disabled()
{
var element = Serialize(new TenantConfiguration { PlanTier = "free", BootstrapTokenEnabled = false });
Assert.False(element.GetProperty("bootstrapTokenEnabled").GetBoolean());
}
[Fact]
public void Payload_UnrestrictedMode_CommunityTenant_ForcedOff_DespiteBothFlags()
{
// Fail-closed re-gate: a downgraded/expired tenant keeps its stored flags but the
// effective value must be false.
var element = Serialize(new TenantConfiguration
{
PlanTier = "free",
UnrestrictedModeEnabled = true,
UnrestrictedMode = true,
});
Assert.False(element.GetProperty("unrestrictedMode").GetBoolean());
}
[Fact]
public void Payload_UnrestrictedMode_ProWithoutGaGate_Off()
{
var element = Serialize(new TenantConfiguration
{
PlanTier = "pro",
UnrestrictedModeEnabled = false,
UnrestrictedMode = true,
});
Assert.False(element.GetProperty("unrestrictedMode").GetBoolean());
}
[Fact]
public void Payload_NullableFlags_FallBackToAgentSideDefaults()
{
@@ -159,12 +210,14 @@ public class GetTenantFeatureFlagsPayloadTests
Assert.Equal("community", element.GetProperty("entitlements").GetProperty("mcpUsagePlan").GetString());
}
[Fact]
public void Payload_EnterpriseTier_NotTrial()
[Theory]
[InlineData("pro")]
[InlineData("enterprise")] // legacy stored value resolves — and reports — as "pro"
public void Payload_PermanentProTier_NotTrial(string storedTier)
{
var element = Serialize(new TenantConfiguration { PlanTier = "enterprise" });
var element = Serialize(new TenantConfiguration { PlanTier = storedTier });
Assert.Equal("enterprise", element.GetProperty("edition").GetString());
Assert.Equal("pro", element.GetProperty("edition").GetString());
Assert.False(element.GetProperty("isTrial").GetBoolean());
Assert.False(element.GetProperty("trialAvailable").GetBoolean());
Assert.Equal(365, element.GetProperty("entitlements").GetProperty("retentionCapDays").GetInt32());
@@ -178,7 +231,7 @@ public class GetTenantFeatureFlagsPayloadTests
var expiry = Now.AddDays(10);
var element = Serialize(new TenantConfiguration { TrialExpiresUtc = expiry, TrialConsumed = true });
Assert.Equal("enterprise", element.GetProperty("edition").GetString());
Assert.Equal("pro", element.GetProperty("edition").GetString());
Assert.True(element.GetProperty("isTrial").GetBoolean());
Assert.False(element.GetProperty("trialAvailable").GetBoolean());
Assert.Equal(expiry, element.GetProperty("trialExpiresUtc").GetDateTime());
@@ -131,14 +131,14 @@ public class McpQuotaServiceTests
}
[Fact]
public async Task Check_EnterpriseEdition_UsesEnterpriseFallbackLimits()
public async Task Check_ProEdition_UsesProFallbackLimits()
{
var svc = Build(UsageRepo(("20260707", 100)), edition: TenantEdition.Enterprise);
var svc = Build(UsageRepo(("20260707", 100)), edition: TenantEdition.Pro);
var d = await svc.CheckAsync(Oid, Upn, TenantId);
Assert.True(d.Allowed);
Assert.Equal("enterprise", d.Plan);
Assert.Equal("pro", d.Plan);
Assert.Equal(1000, d.DailyLimit);
Assert.Equal(20000, d.MonthlyLimit);
}
@@ -36,7 +36,7 @@ public class McpUserServiceDelegatedTests
_adminRepo.Object, cache, NullLogger<GlobalAdminService>.Instance) { CallBase = false };
_delegatedAdmin = new Mock<DelegatedAdminService>(
_adminRepo.Object,
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Enterprise),
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Pro),
cache, NullLogger<DelegatedAdminService>.Instance) { CallBase = false };
_adminConfig = new Mock<AdminConfigurationService>(
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance, cache) { CallBase = false };
@@ -89,7 +89,7 @@ public class PolicyEnforcementMiddlewareTests
// mechanics; the Enterprise-only edition gate is covered by DelegatedAdminEditionGateTests.
var delegatedAdmin = new DelegatedAdminService(
repo.Object,
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Enterprise),
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Pro),
cache,
NullLogger<DelegatedAdminService>.Instance);
var tenantAdmins = new TenantAdminsService(repo.Object, cache, NullLogger<TenantAdminsService>.Instance);
@@ -29,7 +29,7 @@ public class RevokeEnforcementTests
private static DelegatedAdminService BuildService(Mock<IAdminRepository> repo) =>
new(
repo.Object,
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Enterprise),
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Pro),
new MemoryCache(new MemoryCacheOptions()),
NullLogger<DelegatedAdminService>.Instance);
@@ -34,7 +34,7 @@ public class TenantEntitlementServiceTests
public async Task GetEdition_EnterpriseTier_ReturnsEnterprise()
{
var (svc, _) = Build(new TenantConfiguration { TenantId = TenantId, PlanTier = "enterprise" });
Assert.Equal(TenantEdition.Enterprise, await svc.GetEditionAsync(TenantId));
Assert.Equal(TenantEdition.Pro, await svc.GetEditionAsync(TenantId));
}
[Fact]
@@ -46,7 +46,7 @@ public class TenantEntitlementServiceTests
PlanTier = "free",
TrialExpiresUtc = Now.AddDays(3)
});
Assert.Equal(TenantEdition.Enterprise, await svc.GetEditionAsync(TenantId));
Assert.Equal(TenantEdition.Pro, await svc.GetEditionAsync(TenantId));
}
[Fact]
@@ -111,15 +111,78 @@ public class TenantEntitlementServiceTests
[InlineData("free", 60, 60)] // below the cap — unchanged
[InlineData("free", 91, 90)] // above the Community cap — clamped
[InlineData("free", 180, 90)] // legacy stored value above cap — clamped
[InlineData("enterprise", 180, 180)]
[InlineData("enterprise", 365, 365)]
[InlineData("enterprise", 400, 365)] // above the Enterprise cap — clamped
[InlineData("pro", 180, 180)]
[InlineData("pro", 365, 365)]
[InlineData("pro", 400, 365)] // above the Pro cap — clamped
[InlineData("enterprise", 180, 180)] // legacy stored value — Pro cap applies
[InlineData("enterprise", 400, 365)]
public void GetEffectiveRetentionDays_ClampsToEditionCap(string tier, int stored, int expected)
{
var config = new TenantConfiguration { TenantId = TenantId, PlanTier = tier, DataRetentionDays = stored };
Assert.Equal(expected, TenantEntitlementService.GetEffectiveRetentionDays(config, Now));
}
// ── IsBootstrapEnabled / IsUnrestrictedModeActive (effective feature gates) ──
[Theory]
[InlineData("pro", false, true)] // included in the plan — no GA flag needed
[InlineData("enterprise", false, true)] // legacy stored value counts as Pro
[InlineData("free", true, true)] // Community + GA flag (additive escape hatch)
[InlineData("free", false, false)] // Community without flag → off
public void IsBootstrapEnabled_PlanOrFlag(string tier, bool gaFlag, bool expected)
{
var config = new TenantConfiguration { TenantId = TenantId, PlanTier = tier, BootstrapTokenEnabled = gaFlag };
Assert.Equal(expected, TenantEntitlementService.IsBootstrapEnabled(config, Now));
}
[Fact]
public void IsBootstrapEnabled_ActiveTrial_CountsAsPro_AndExpiryTurnsItOff()
{
var config = new TenantConfiguration
{
TenantId = TenantId,
PlanTier = "free",
BootstrapTokenEnabled = false,
TrialExpiresUtc = Now.AddDays(1)
};
Assert.True(TenantEntitlementService.IsBootstrapEnabled(config, Now));
Assert.False(TenantEntitlementService.IsBootstrapEnabled(config, Now.AddDays(2)));
}
[Theory]
[InlineData("pro", true, true, true)] // all three conditions met
[InlineData("pro", true, false, false)] // tenant admin has not opted in
[InlineData("pro", false, true, false)] // GA on-request gate missing
[InlineData("free", true, true, false)] // edition re-gate: Community never unrestricted
public void IsUnrestrictedModeActive_RequiresProAndGateAndToggle(
string tier, bool gate, bool toggle, bool expected)
{
var config = new TenantConfiguration
{
TenantId = TenantId,
PlanTier = tier,
UnrestrictedModeEnabled = gate,
UnrestrictedMode = toggle
};
Assert.Equal(expected, TenantEntitlementService.IsUnrestrictedModeActive(config, Now));
}
[Fact]
public void IsUnrestrictedModeActive_TrialExpiry_RearmsGuardrails()
{
// A trial tenant may have Unrestricted Mode granted+enabled; expiry must fail closed.
var config = new TenantConfiguration
{
TenantId = TenantId,
PlanTier = "free",
UnrestrictedModeEnabled = true,
UnrestrictedMode = true,
TrialExpiresUtc = Now.AddDays(1)
};
Assert.True(TenantEntitlementService.IsUnrestrictedModeActive(config, Now));
Assert.False(TenantEntitlementService.IsUnrestrictedModeActive(config, Now.AddDays(2)));
}
[Fact]
public void GetEffectiveRetentionDays_ZeroInfinite_IsNeverClamped()
{
@@ -68,11 +68,11 @@ public class DelegatedAdminManagementFunction
if (validationError != null)
return await Bad(req, validationError);
// Edition note: TARGET tenants may be any edition — an MSP on Enterprise may manage Community
// customers. The Enterprise requirement applies to the delegated admin's HOME tenant and is
// Edition note: TARGET tenants may be any edition — an MSP on Pro may manage Community
// customers. The Pro requirement applies to the delegated admin's HOME tenant and is
// enforced at resolve time (DelegatedAdminService.GetScopeAsync gates on the JWT tid). It cannot
// be checked here: at grant time only the UPN is known, and UPN-domain → tenant mapping is not
// reliable (multi-domain tenants). Grants to non-Enterprise-homed admins are simply inert.
// reliable (multi-domain tenants). Grants to non-Pro-homed admins are simply inert.
var entry = await _delegatedAdminService.UpsertAsync(
body!.Upn, body.TenantId, role,
Constants.DelegatedStatus.Active, Constants.DelegatedSource.OperatorGranted, currentUpn ?? "");
@@ -73,7 +73,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
// Feature gate: bootstrap endpoints only available when explicitly enabled for this tenant
var (config, tenantExists) = await _configService.TryGetConfigurationAsync(tenantId);
if (!tenantExists || !config.BootstrapTokenEnabled)
if (!tenantExists || !TenantEntitlementService.IsBootstrapEnabled(config, DateTime.UtcNow))
{
return req.CreateResponse(HttpStatusCode.NotFound);
}
@@ -88,7 +88,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
// Feature gate: bootstrap endpoints only available when explicitly enabled for this tenant
var (config, tenantExists) = await _configService.TryGetConfigurationAsync(registration.TenantId);
if (!tenantExists || !config.BootstrapTokenEnabled)
if (!tenantExists || !TenantEntitlementService.IsBootstrapEnabled(config, DateTime.UtcNow))
{
return new RegisterSessionOutput { HttpResponse = req.CreateResponse(HttpStatusCode.NotFound) };
}
@@ -72,7 +72,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
// Feature gate: bootstrap endpoints only available when explicitly enabled for this tenant
var (config, tenantExists) = await _configService.TryGetConfigurationAsync(tenantId);
if (!tenantExists || !config.BootstrapTokenEnabled)
if (!tenantExists || !TenantEntitlementService.IsBootstrapEnabled(config, DateTime.UtcNow))
{
return req.CreateResponse(HttpStatusCode.NotFound);
}
@@ -62,9 +62,9 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
return badReq;
}
// Check if bootstrap token feature is enabled for this tenant
// Check if the bootstrap feature is enabled for this tenant (Pro plan or GA flag)
var tenantConfig = await _configService.GetConfigurationAsync(tenantId);
if (!tenantConfig.BootstrapTokenEnabled)
if (!TenantEntitlementService.IsBootstrapEnabled(tenantConfig, DateTime.UtcNow))
{
var disabled = req.CreateResponse(HttpStatusCode.Forbidden);
await disabled.WriteAsJsonAsync(new { error = "Bootstrap token feature is not enabled for this tenant" });
@@ -95,7 +95,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
// Feature gate — same generic message as not-found so a disabled tenant is
// indistinguishable from an unknown code (no enumeration oracle).
var tenantConfig = await _configService.GetConfigurationAsync(session.TenantId);
if (!tenantConfig.BootstrapTokenEnabled)
if (!TenantEntitlementService.IsBootstrapEnabled(tenantConfig, DateTime.UtcNow))
{
_logger.LogWarning("Bootstrap script for code {Code} rejected — feature disabled for tenant {TenantId}", code, session.TenantId);
return await ScriptResponseAsync(
@@ -41,9 +41,9 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
var requestCtx = req.GetRequestContext();
var tenantId = requestCtx.TargetTenantId;
// Check if bootstrap token feature is enabled for this tenant
// Check if the bootstrap feature is enabled for this tenant (Pro plan or GA flag)
var tenantConfig = await _configService.GetConfigurationAsync(tenantId);
if (!tenantConfig.BootstrapTokenEnabled)
if (!TenantEntitlementService.IsBootstrapEnabled(tenantConfig, DateTime.UtcNow))
{
// Return empty list instead of error — feature simply not visible
var emptyResponse = req.CreateResponse(HttpStatusCode.OK);
@@ -45,9 +45,9 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
var tenantId = requestCtx.TargetTenantId;
var userIdentifier = requestCtx.UserPrincipalName;
// Check if bootstrap token feature is enabled for this tenant
// Check if the bootstrap feature is enabled for this tenant (Pro plan or GA flag)
var tenantConfig = await _configService.GetConfigurationAsync(tenantId);
if (!tenantConfig.BootstrapTokenEnabled)
if (!TenantEntitlementService.IsBootstrapEnabled(tenantConfig, DateTime.UtcNow))
{
var disabled = req.CreateResponse(HttpStatusCode.Forbidden);
await disabled.WriteAsJsonAsync(new { error = "Bootstrap token feature is not enabled for this tenant" });
@@ -79,9 +79,9 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
return notFound;
}
// Check if bootstrap token feature is enabled for the session's tenant
// Check if the bootstrap feature is enabled for the session's tenant (Pro plan or GA flag)
var tenantConfig = await _configService.GetConfigurationAsync(session.TenantId);
if (!tenantConfig.BootstrapTokenEnabled)
if (!TenantEntitlementService.IsBootstrapEnabled(tenantConfig, DateTime.UtcNow))
{
_logger.LogWarning("Bootstrap code {Code} rejected — feature disabled for tenant {TenantId}", code, session.TenantId);
var disabled = req.CreateResponse(HttpStatusCode.NotFound);
@@ -308,7 +308,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
NtpServer = string.IsNullOrEmpty(tenantConfig.NtpServer) ? "time.windows.com" : tenantConfig.NtpServer,
EnableTimezoneAutoSet = tenantConfig.EnableTimezoneAutoSet ?? false,
SendTraceEvents = tenantConfig.SendTraceEvents,
UnrestrictedMode = tenantConfig.UnrestrictedModeEnabled && tenantConfig.UnrestrictedMode,
UnrestrictedMode = TenantEntitlementService.IsUnrestrictedModeActive(tenantConfig, DateTime.UtcNow),
GatherRules = gatherRules,
ImeLogPatterns = imeLogPatterns,
WhiteGloveSealingPatternIds = adminConfig.GetWhiteGloveSealingPatternIds(),
@@ -70,12 +70,14 @@ namespace AutopilotMonitor.Functions.Functions.Config
{
var edition = FeatureEntitlementCatalog.ResolveEdition(config.PlanTier, config.TrialExpiresUtc, nowUtc);
var entitlements = FeatureEntitlementCatalog.Get(edition);
var isTrial = edition == TenantEdition.Enterprise &&
!string.Equals(config.PlanTier?.Trim(), FeatureEntitlementCatalog.EnterpriseTierName, StringComparison.OrdinalIgnoreCase);
var isTrial = edition == TenantEdition.Pro &&
!FeatureEntitlementCatalog.IsPermanentProTier(config.PlanTier);
return new
{
bootstrapTokenEnabled = config.BootstrapTokenEnabled,
// EFFECTIVE bootstrap availability (Pro includes it; the GA flag is the additive
// Community enable) — field name kept for web compatibility.
bootstrapTokenEnabled = TenantEntitlementService.IsBootstrapEnabled(config, nowUtc),
// Session-detail "Collect Logs" button: whether an on-demand diagnostics upload can
// succeed right now (mode not Off + a usable destination). Members below Admin cannot
// read the full config, so this boolean is their only signal. Deliberately exposes no
@@ -92,10 +94,10 @@ namespace AutopilotMonitor.Functions.Functions.Config
showScriptOutput = config.ShowScriptOutput ?? true,
enableSoftwareInventoryAnalyzer = config.EnableSoftwareInventoryAnalyzer ?? false,
enableIntegrityBypassAnalyzer = config.EnableIntegrityBypassAnalyzer ?? true,
// Gather-rules page validation indicator. UnrestrictedMode itself is just a display
// hint — the privileged toggle is UnrestrictedModeEnabled (admin-only, stays in
// the full config response).
unrestrictedMode = config.UnrestrictedMode,
// Gather-rules page validation indicator. EFFECTIVE value (requires Pro edition +
// GA gate + tenant toggle) — the privileged toggle is UnrestrictedModeEnabled
// (admin-only, stays in the full config response).
unrestrictedMode = TenantEntitlementService.IsUnrestrictedModeActive(config, nowUtc),
// Edition/entitlement surface (read-time resolution — non-sensitive by design):
// drives the EditionBadge, trial CTA and retention hint in the web UI.
edition = edition.ToString().ToLowerInvariant(),
@@ -54,7 +54,9 @@ namespace AutopilotMonitor.Functions.Functions.Config
/// <summary>
/// PATCH /api/config/{tenantId}/plan — GlobalAdminOnly (catalog-enforced).
/// Body: { "planTier"?: "community"|"enterprise", "trialExpiresUtc"?: ISO-8601 | null }.
/// Body: { "planTier"?: "community"|"pro", "trialExpiresUtc"?: ISO-8601 | null }.
/// The legacy stored value "enterprise" stays readable (resolves as Pro) but is no longer
/// accepted on writes.
/// Setting a trial date grants/extends the trial (TrialConsumed is NOT touched — GA
/// re-grants stay possible); explicit null ends the trial. Absent properties are unchanged.
/// </summary>
@@ -96,10 +98,10 @@ namespace AutopilotMonitor.Functions.Functions.Config
newPlanTier = tierProp.GetString()!.Trim().ToLowerInvariant();
if (newPlanTier != FeatureEntitlementCatalog.CommunityTierName &&
newPlanTier != FeatureEntitlementCatalog.EnterpriseTierName)
newPlanTier != FeatureEntitlementCatalog.ProTierName)
{
return await BadRequestAsync(req,
$"Invalid planTier. Valid values: {FeatureEntitlementCatalog.CommunityTierName}, {FeatureEntitlementCatalog.EnterpriseTierName}");
$"Invalid planTier. Valid values: {FeatureEntitlementCatalog.CommunityTierName}, {FeatureEntitlementCatalog.ProTierName}");
}
}
@@ -194,8 +196,8 @@ namespace AutopilotMonitor.Functions.Functions.Config
/// <summary>
/// POST /api/config/{tenantId}/trial — TenantAdminOrGA (catalog-enforced). Self-service
/// 30-day Enterprise trial, exactly once per tenant. 409 when the trial was already
/// consumed or the tenant is already effectively Enterprise.
/// 30-day Pro trial, exactly once per tenant. 409 when the trial was already
/// consumed or the tenant is already effectively Pro.
/// </summary>
[Function("StartTenantTrial")]
public async Task<HttpResponseData> StartTrial(
@@ -224,18 +226,18 @@ namespace AutopilotMonitor.Functions.Functions.Config
await conflict.WriteAsJsonAsync(new
{
error = "TrialAlreadyConsumed",
message = "This tenant has already used its one self-service Enterprise trial. Contact support to extend."
message = "This tenant has already used its one self-service Pro trial. Contact support to extend."
});
return conflict;
}
if (FeatureEntitlementCatalog.ResolveEdition(config.PlanTier, config.TrialExpiresUtc, nowUtc) == TenantEdition.Enterprise)
if (FeatureEntitlementCatalog.ResolveEdition(config.PlanTier, config.TrialExpiresUtc, nowUtc) == TenantEdition.Pro)
{
var conflict = req.CreateResponse(HttpStatusCode.Conflict);
await conflict.WriteAsJsonAsync(new
{
error = "AlreadyEnterprise",
message = "This tenant is already on the Enterprise edition."
error = "AlreadyPro",
message = "This tenant is already on the Pro plan."
});
return conflict;
}
@@ -267,7 +269,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
tenantId = requestCtx.TargetTenantId,
trialStartedUtc = config.TrialStartedUtc,
trialExpiresUtc = config.TrialExpiresUtc,
effectiveEdition = FeatureEntitlementCatalog.EnterpriseTierName
effectiveEdition = FeatureEntitlementCatalog.ProTierName
});
return response;
}
@@ -223,7 +223,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
await badRequest.WriteAsJsonAsync(new
{
success = false,
message = $"Data retention must be between 7 and {cap} days for your plan. Upgrade to Enterprise for up to 365 days."
message = $"Data retention must be between 7 and {cap} days for your plan. Upgrade to Pro for up to 365 days."
});
return badRequest;
}
@@ -94,7 +94,7 @@ public class AuthFunction
// gate below). A genuine first-user still gets their config created by HandleNewTenantDomainAsync.
var tenantConfigTask = _tenantConfigService.TryGetConfigurationAsync(tenantId);
var globalRoleTask = _globalAdminService.GetGlobalRoleAsync(upn);
// tenantId = JWT tid = the caller's home tenant — gates the delegated (MSP) scope (Enterprise-only seat).
// tenantId = JWT tid = the caller's home tenant — gates the delegated (MSP) scope (Pro-only seat).
var delegatedScopeTask = _delegatedAdminService.GetScopeAsync(upn, tenantId);
var isApprovedTask = _previewWhitelistService.IsApprovedAsync(tenantId);
var membershipTask = _tenantAdminsService.GetTableMembershipAsync(tenantId, upn);
@@ -469,8 +469,12 @@ public class AuthFunction
role,
canManageBootstrapTokens,
hasMcpAccess = mcpCheck.IsAllowed,
bootstrapTokenEnabled = tenantConfig.BootstrapTokenEnabled,
unrestrictedModeEnabled = tenantConfig.UnrestrictedModeEnabled
// EFFECTIVE availability flags (drive sidebar/section visibility): bootstrap is
// included in Pro, Unrestricted Mode requires Pro + the GA on-request gate.
bootstrapTokenEnabled = TenantEntitlementService.IsBootstrapEnabled(tenantConfig, DateTime.UtcNow),
unrestrictedModeEnabled =
FeatureEntitlementCatalog.Get(TenantEntitlementService.ResolveEdition(tenantConfig, DateTime.UtcNow)).UnrestrictedModeAvailable
&& tenantConfig.UnrestrictedModeEnabled
}, needsAutoAdmin);
}
@@ -163,7 +163,7 @@ public class McpUserFunction
var principal = context.GetUser();
var upn = principal?.GetUserPrincipalName();
// JWT tid = the caller's home tenant — gates the delegated (MSP) auto-grant (Enterprise-only seat).
// JWT tid = the caller's home tenant — gates the delegated (MSP) auto-grant (Pro-only seat).
var result = await _mcpUserService.IsAllowedAsync(upn, principal?.GetTenantId());
var response = req.CreateResponse(result.IsAllowed ? HttpStatusCode.OK : HttpStatusCode.Forbidden);
@@ -100,7 +100,7 @@ namespace AutopilotMonitor.Functions.Functions.Infrastructure
{
// realtime/groups/join is not a tenant-scoped route, so the middleware did not
// resolve the delegated scope onto RequestContext — resolve it here, bounded to
// the requested tenant only. Home tid gates the MSP seat (Enterprise-only).
// the requested tenant only. Home tid gates the MSP seat (Pro-only).
var scope = await _delegatedAdminService.GetScopeAsync(userEmail, userTenantId);
allowedCrossTenant = scope.RoleFor(requestedTenantId) != null;
if (allowedCrossTenant)
@@ -10,7 +10,7 @@ using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Maintenance;
/// <summary>
/// Daily informational sweep over tenant Enterprise trials. NOT load-bearing — trial expiry
/// Daily informational sweep over tenant Pro trials. NOT load-bearing — trial expiry
/// degrades a tenant to Community at READ time (FeatureEntitlementCatalog.ResolveEdition);
/// this timer only surfaces the transitions as ops events so the operator has visibility:
/// <list type="bullet">
@@ -20,7 +20,7 @@ namespace AutopilotMonitor.Functions.Functions.Maintenance;
/// <see cref="ExpiringHeadsUpDays"/> days (re-emitted each daily run until expiry —
/// acceptable for an Info-tier heads-up, no dedupe state to maintain).</item>
/// </list>
/// Tenants whose stored <c>PlanTier</c> is already the permanent "enterprise" are skipped —
/// Tenants whose stored <c>PlanTier</c> is already a permanent Pro tier ("pro"/legacy "enterprise") are skipped —
/// their trial timestamps are inert leftovers and expiry changes nothing.
/// Failure never poisons the timer; the next run re-sweeps.
/// </summary>
@@ -93,8 +93,8 @@ public class TrialExpirySweepFunction
if (config.TrialExpiresUtc is not DateTime expiry)
continue;
// Permanent Enterprise: the trial timestamps are inert leftovers — expiry changes nothing.
if (string.Equals(config.PlanTier?.Trim(), FeatureEntitlementCatalog.EnterpriseTierName, StringComparison.OrdinalIgnoreCase))
// Permanent Pro: the trial timestamps are inert leftovers — expiry changes nothing.
if (FeatureEntitlementCatalog.IsPermanentProTier(config.PlanTier))
continue;
result.TrialsSeen++;
@@ -166,7 +166,7 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
var isScopedRoute = catalogEntry.TenantScoping is TenantScoping.RouteParam or TenantScoping.QueryParam;
if (!hasGlobalScope && !string.IsNullOrEmpty(upn) && isScopedRoute && crossTenant)
{
// Home-tenant (JWT tid) gates the delegated scope: MSP seats require an Enterprise home tenant.
// Home-tenant (JWT tid) gates the delegated scope: MSP seats require a Pro home tenant.
var scope = await _delegatedAdminService.GetScopeAsync(upn, jwtTenantId);
delegatedRole = scope.RoleFor(namedTarget);
if (delegatedRole != null)
@@ -579,7 +579,7 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
if (globalRole != null)
return CatalogDecisionResult.Allow(userIdentifier, globalRole, "GlobalScope");
// Home-tenant (JWT tid) gates the delegated scope: MSP seats require an Enterprise home tenant.
// Home-tenant (JWT tid) gates the delegated scope: MSP seats require a Pro home tenant.
var scope = await _delegatedAdminService.GetScopeAsync(upn, jwtTenantId);
if (!scope.IsEmpty)
return CatalogDecisionResult.Allow(
@@ -83,7 +83,7 @@ public class UserRateLimitMiddleware : IFunctionsWorkerMiddleware
tenantOverride = tenantConfig?.CustomUserRateLimitRequestsPerMinute;
}
// Edition entitlement floor (Enterprise: 150/min): follows the caller's HOME tenant
// Edition entitlement floor (Pro: 150/min): follows the caller's HOME tenant
// (JWT tid) — an MSP/delegated user rides on the edition of the tenant that pays for
// their seat, not the tenant they happen to be viewing. Resolution is fail-closed
// (any error → Community → null floor → admin default applies) and served from the
@@ -519,8 +519,8 @@ public static class EndpointAccessPolicyCatalog
new("POST", "global/tenant-groups/{groupId}/assignees", EndpointPolicy.GlobalAdminOnly),
new("DELETE", "global/tenant-groups/{groupId}/assignees/{upn}", EndpointPolicy.GlobalAdminOnly),
new("PATCH", "config/{tenantId}/plan", EndpointPolicy.GlobalAdminOnly, TenantScoping.RouteParam),
// Self-service Enterprise trial — a tenant admin may start their own tenant's one-time
// trial; the handler enforces the once-per-tenant + already-Enterprise conflicts (409).
// Self-service Pro trial — a tenant admin may start their own tenant's one-time
// trial; the handler enforces the once-per-tenant + already-Pro conflicts (409).
new("POST", "config/{tenantId}/trial", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
new("GET", "global/config/plan-tiers", EndpointPolicy.GlobalReadOrAdmin),
new("PUT", "global/config/plan-tiers", EndpointPolicy.GlobalAdminOnly),
@@ -3,13 +3,13 @@ using System;
namespace AutopilotMonitor.Functions.Security
{
/// <summary>
/// The two effective tenant editions. Anything that is not provably Enterprise is Community
/// (fail-closed) — including the legacy stored tiers "free" and "pro" and unknown values.
/// The two effective tenant editions. Anything that is not provably Pro is Community
/// (fail-closed) — including the legacy stored tier "free" and unknown values.
/// </summary>
public enum TenantEdition
{
Community,
Enterprise
Pro
}
/// <summary>
@@ -43,11 +43,27 @@ namespace AutopilotMonitor.Functions.Security
/// <summary>
/// Whether users HOMED in this tenant may hold delegated ("MSP") admin scopes over other
/// tenants. The gate applies to the delegated admin's home tenant (JWT tid) — the managed
/// TARGET tenants may be any edition (an Enterprise MSP may manage Community customers).
/// TARGET tenants may be any edition (a Pro MSP may manage Community customers).
/// Enforced at resolve time in DelegatedAdminService.GetScopeAsync.
/// </summary>
public bool DelegatedAdminAllowed { get; init; }
/// <summary>
/// Whether the OOBE bootstrap feature (bootstrap sessions/codes) is included in the plan.
/// When true, bootstrap endpoints work without the per-tenant GA flag
/// (TenantConfiguration.BootstrapTokenEnabled remains an additive per-tenant enable for
/// Community). Resolved via <see cref="Services.TenantEntitlementService.IsBootstrapEnabled"/>.
/// </summary>
public bool BootstrapIncluded { get; init; }
/// <summary>
/// Whether Unrestricted Mode may be active for tenants of this edition. Availability stays
/// on-request (the GA-only UnrestrictedModeEnabled gate) — this flag additionally binds the
/// feature to the plan so a trial expiry / downgrade re-arms the guardrails (fail-closed).
/// Resolved via <see cref="Services.TenantEntitlementService.IsUnrestrictedModeActive"/>.
/// </summary>
public bool UnrestrictedModeAvailable { get; init; }
/// <summary>Default MCP usage plan name for users of this tenant (per-user override wins).</summary>
public string McpUsagePlanName { get; init; } = string.Empty;
@@ -61,16 +77,23 @@ namespace AutopilotMonitor.Functions.Security
/// <summary>
/// Single source of truth for edition resolution and the per-edition entitlement matrix.
///
/// Resolution is computed at READ time: Enterprise ⇔ PlanTier == "enterprise" OR an active
/// trial (TrialExpiresUtc strictly in the future). Trial expiry therefore degrades the tenant
/// automatically without any timer or sweep. Everything else — null, empty, "free", "pro",
/// Resolution is computed at READ time: Pro ⇔ PlanTier == "pro" (or the legacy stored value
/// "enterprise", kept readable so existing rows need no migration) OR an active trial
/// (TrialExpiresUtc strictly in the future). Trial expiry therefore degrades the tenant
/// automatically without any timer or sweep. Everything else — null, empty, "free",
/// unknown — is Community (fail-closed, no data migration required).
/// </summary>
public static class FeatureEntitlementCatalog
{
/// <summary>Write-side canonical tier names (the only values the plan endpoint accepts).</summary>
public const string CommunityTierName = "community";
public const string EnterpriseTierName = "enterprise";
public const string ProTierName = "pro";
/// <summary>
/// Stored tier value written before the 2026-07 Enterprise→Pro rename. Read-side alias for
/// <see cref="ProTierName"/> only — the plan endpoint no longer accepts it on writes.
/// </summary>
public const string LegacyEnterpriseTierName = "enterprise";
private static readonly EditionEntitlements Community = new()
{
@@ -79,41 +102,57 @@ namespace AutopilotMonitor.Functions.Security
UserRateLimitPerMinute = null, // AdminConfiguration default (120) applies
DeviceRateLimitPerMinute = null, // AdminConfiguration default (100) applies
DelegatedAdminAllowed = false,
BootstrapIncluded = false,
UnrestrictedModeAvailable = false,
McpUsagePlanName = CommunityTierName,
McpDailyRequestLimit = 100,
McpMonthlyRequestLimit = 3000
};
private static readonly EditionEntitlements Enterprise = new()
private static readonly EditionEntitlements Pro = new()
{
Edition = TenantEdition.Enterprise,
Edition = TenantEdition.Pro,
RetentionCapDays = 365,
UserRateLimitPerMinute = 150,
DeviceRateLimitPerMinute = 150,
DelegatedAdminAllowed = true,
McpUsagePlanName = EnterpriseTierName,
BootstrapIncluded = true,
UnrestrictedModeAvailable = true,
McpUsagePlanName = ProTierName,
McpDailyRequestLimit = 1000,
McpMonthlyRequestLimit = 20000
};
/// <summary>
/// True when the stored tier is a PERMANENT Pro tier ("pro", or the legacy stored value
/// "enterprise"), independent of any trial. Used to separate paid tenants from trial
/// tenants (feature-flags isTrial, trial-expiry sweep skip).
/// </summary>
public static bool IsPermanentProTier(string? planTier)
{
var trimmed = planTier?.Trim();
return string.Equals(trimmed, ProTierName, StringComparison.OrdinalIgnoreCase)
|| string.Equals(trimmed, LegacyEnterpriseTierName, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Resolves the effective edition from the stored plan tier and trial expiry.
/// Enterprise ⇔ exact tier "enterprise" (case-insensitive) OR trialExpiresUtc &gt; nowUtc.
/// Pro ⇔ tier "pro"/legacy "enterprise" (case-insensitive) OR trialExpiresUtc &gt; nowUtc.
/// A trial expiring exactly at <paramref name="nowUtc"/> is already over (strict &gt;).
/// </summary>
public static TenantEdition ResolveEdition(string? planTier, DateTime? trialExpiresUtc, DateTime nowUtc)
{
if (string.Equals(planTier?.Trim(), EnterpriseTierName, StringComparison.OrdinalIgnoreCase))
return TenantEdition.Enterprise;
if (IsPermanentProTier(planTier))
return TenantEdition.Pro;
if (trialExpiresUtc.HasValue && trialExpiresUtc.Value > nowUtc)
return TenantEdition.Enterprise;
return TenantEdition.Pro;
return TenantEdition.Community;
}
/// <summary>Returns the entitlement set for an edition. Unknown enum values → Community (fail-closed).</summary>
public static EditionEntitlements Get(TenantEdition edition) =>
edition == TenantEdition.Enterprise ? Enterprise : Community;
edition == TenantEdition.Pro ? Pro : Community;
}
}
@@ -73,7 +73,7 @@ namespace AutopilotMonitor.Functions.Security
/// <summary>
/// Resolves the effective device (agent/cert) rate limit for a tenant:
/// the per-tenant override if set, otherwise the global default raised to the tenant
/// edition's entitlement floor (Enterprise: 150/min — see FeatureEntitlementCatalog).
/// edition's entitlement floor (Pro: 150/min — see FeatureEntitlementCatalog).
/// The global AdminConfiguration read is served from a 5-minute in-memory cache.
/// </summary>
private async Task<int> ResolveDeviceRateLimitAsync(TenantConfiguration config)
@@ -165,8 +165,8 @@ namespace AutopilotMonitor.Functions.Security
};
}
// Verify bootstrap token feature is enabled for this tenant
if (!config.BootstrapTokenEnabled)
// Verify the bootstrap feature is enabled for this tenant (Pro plan or GA flag)
if (!Services.TenantEntitlementService.IsBootstrapEnabled(config, DateTime.UtcNow))
{
_logger.LogWarning("Rejected bootstrap token: feature disabled for tenant {TenantId}", tenantId);
return new SecurityValidationResult
@@ -13,8 +13,8 @@ namespace AutopilotMonitor.Functions.Services;
/// Externally this is surfaced as "MSP mode".
///
/// The scope is keyed on UPN; assignment RESOLUTION is tid-agnostic (identical to <see cref="GlobalAdminService"/>),
/// but the effective scope is GATED on the caller's home tenant (JWT tid) being Enterprise — the MSP
/// seat is an Enterprise capability of the tenant the admin is homed in, while the managed targets may
/// but the effective scope is GATED on the caller's home tenant (JWT tid) being Pro — the MSP
/// seat is a Pro capability of the tenant the admin is homed in, while the managed targets may
/// be any edition. Resolution is cached briefly (UPN-keyed, gate applied per call); every mutation
/// invalidates the cache.
/// </summary>
@@ -50,10 +50,10 @@ public class DelegatedAdminService
/// Returns an empty (never null) scope for a UPN with no effective assignments.
/// <para>
/// <b>Entitlement gate (single choke-point for ALL delegated-scope consumers):</b> delegated ("MSP")
/// access is an Enterprise capability of the caller's HOME tenant — the tenant that pays for the MSP
/// access is a Pro capability of the caller's HOME tenant — the tenant that pays for the MSP
/// seat (<paramref name="callerHomeTenantId"/> = the validated JWT tid, unforgeable). The MANAGED
/// (target) tenants may be any edition — an MSP on Enterprise may manage Community customers.
/// Null/empty home tid or a non-Enterprise home tenant ⇒ empty scope (fail-closed). Grant rows stay
/// (target) tenants may be any edition — an MSP on Pro may manage Community customers.
/// Null/empty home tid or a non-Pro home tenant ⇒ empty scope (fail-closed). Grant rows stay
/// untouched; they become inert and resurrect when the home tenant upgrades / trials.
/// The gate sits AFTER the cache read so the cached scope stays pure (keyed on UPN only) and a
/// home-tenant edition change converges within the entitlement service's own config-cache TTL.
@@ -114,9 +114,9 @@ public class DelegatedAdminService
}
// Entitlement gate (single choke-point for ALL delegated-scope consumers — policy rescue,
// AllowedTenantIds, delegated config/all, MCP auto-grant): delegated ("MSP") access is an
// Enterprise capability of the caller's HOME tenant (the tenant paying for the MSP seat) —
// NOT of the managed targets. An Enterprise MSP may manage Community customers. Resolution
// AllowedTenantIds, delegated config/all, MCP auto-grant): delegated ("MSP") access is a
// Pro capability of the caller's HOME tenant (the tenant paying for the MSP seat) —
// NOT of the managed targets. A Pro MSP may manage Community customers. Resolution
// failure / unknown home tenant counts as Community (fail-closed inside the entitlement
// service), which empties the scope. Grant rows stay untouched — inert until upgrade/trial.
var scope = new DelegatedScope(tenantRoles);
@@ -125,7 +125,7 @@ public class DelegatedAdminService
}
/// <summary>
/// Empties a non-empty scope when the caller's home tenant is not Enterprise. Applied after
/// Empties a non-empty scope when the caller's home tenant is not Pro. Applied after
/// every cache read/write so the cached scope stays pure (UPN-keyed) — the edition lookup has
/// its own 5-minute config cache, so this adds no per-request storage I/O in the steady state.
/// </summary>
@@ -134,11 +134,11 @@ public class DelegatedAdminService
if (scope.IsEmpty)
return scope;
if (await _entitlementService.GetEditionAsync(callerHomeTenantId) == Security.TenantEdition.Enterprise)
if (await _entitlementService.GetEditionAsync(callerHomeTenantId) == Security.TenantEdition.Pro)
return scope;
_logger.LogInformation(
"[DelegatedAdmin] Suppressing delegated scope for {Upn} — home tenant {HomeTenantId} is not Enterprise (MSP seat requires Enterprise)",
"[DelegatedAdmin] Suppressing delegated scope for {Upn} — home tenant {HomeTenantId} is not Pro (MSP seat requires the Pro plan)",
upn, string.IsNullOrWhiteSpace(callerHomeTenantId) ? "(unknown)" : callerHomeTenantId);
return DelegatedScope.Empty;
}
@@ -158,7 +158,7 @@ namespace AutopilotMonitor.Functions.Services.Deletion
{
var config = await _tenantConfig.GetConfigurationAsync(tenantId).ConfigureAwait(false);
// Edition retention cap (fail-closed backstop): the stored value is clamped to the
// effective edition's cap at read time (Community 90 / Enterprise 365). A tenant whose
// effective edition's cap at read time (Community 90 / Pro 365). A tenant whose
// trial expired, or whose stored value predates the cap, is enforced here even though
// the stored DataRetentionDays is left untouched. days <= 0 stays the GA-only
// "infinite" escape hatch (skipped below, never clamped).
@@ -144,8 +144,8 @@ namespace AutopilotMonitor.Functions.Services
_logger.LogWarning(ex, "[McpQuota] Plan definitions unavailable — using catalog fallback for plan {Plan}", planName);
}
var fallback = string.Equals(planName, FeatureEntitlementCatalog.EnterpriseTierName, StringComparison.OrdinalIgnoreCase)
? FeatureEntitlementCatalog.Get(TenantEdition.Enterprise)
var fallback = FeatureEntitlementCatalog.IsPermanentProTier(planName)
? FeatureEntitlementCatalog.Get(TenantEdition.Pro)
: FeatureEntitlementCatalog.Get(TenantEdition.Community);
return (planName, fallback.McpDailyRequestLimit, fallback.McpMonthlyRequestLimit);
}
@@ -50,7 +50,7 @@ public class McpUserService
/// <summary>
/// Checks if a user is allowed to access the MCP server based on current policy.
/// <paramref name="homeTenantId"/> is the caller's JWT tid (home tenant) — it gates the
/// delegated (MSP) auto-grant path, which requires an Enterprise home tenant. Null (unknown)
/// delegated (MSP) auto-grant path, which requires a Pro home tenant. Null (unknown)
/// fails closed to an empty delegated scope; the other grant paths are unaffected.
/// </summary>
public virtual async Task<McpAccessCheckResult> IsAllowedAsync(string? upn, string? homeTenantId = null)
@@ -464,17 +464,17 @@ namespace AutopilotMonitor.Functions.Services
// Both types are dual-registered in OpsAlertRulesSection.tsx OPS_EVENT_TYPES
// (memory feedback_ops_event_types_dual_register). Dispatched by TrialExpirySweepFunction.
/// <summary>Heads-up: an Enterprise trial ends within the next few days. Info-tier visibility signal.</summary>
/// <summary>Heads-up: a Pro trial ends within the next few days. Info-tier visibility signal.</summary>
public Task RecordTenantTrialExpiringAsync(string tenantId, string? domainName, DateTime trialExpiresUtc, int daysLeft)
{
var tenantLabel = string.IsNullOrWhiteSpace(domainName) ? tenantId : $"{domainName} ({tenantId})";
return WriteAsync(OpsEventCategory.Tenant, "TenantTrialExpiring", OpsEventSeverity.Info,
$"Enterprise trial for tenant {tenantLabel} expires in {daysLeft} day(s) ({trialExpiresUtc:yyyy-MM-dd HH:mm}Z)",
$"Pro trial for tenant {tenantLabel} expires in {daysLeft} day(s) ({trialExpiresUtc:yyyy-MM-dd HH:mm}Z)",
tenantId, "System.TrialSweep", new { domainName, trialExpiresUtc, daysLeft });
}
/// <summary>
/// An Enterprise trial expired within the last sweep window — the tenant silently degraded
/// A Pro trial expired within the last sweep window — the tenant silently degraded
/// to Community at read time (retention cap, rate limits, MSP delegation, MCP plan).
/// Warning-tier: a conversion moment an operator likely wants a Telegram ping for.
/// </summary>
@@ -482,7 +482,7 @@ namespace AutopilotMonitor.Functions.Services
{
var tenantLabel = string.IsNullOrWhiteSpace(domainName) ? tenantId : $"{domainName} ({tenantId})";
return WriteAsync(OpsEventCategory.Tenant, "TenantTrialExpired", OpsEventSeverity.Warning,
$"Enterprise trial for tenant {tenantLabel} expired ({trialExpiredUtc:yyyy-MM-dd HH:mm}Z) — tenant is now Community",
$"Pro trial for tenant {tenantLabel} expired ({trialExpiredUtc:yyyy-MM-dd HH:mm}Z) — tenant is now Community",
tenantId, "System.TrialSweep", new { domainName, trialExpiredUtc });
}
@@ -7,7 +7,7 @@ namespace AutopilotMonitor.Functions.Services
/// no background sync job. Both the device path (<see cref="SecurityValidator"/>) and the user path
/// (<c>UserRateLimitMiddleware</c>) resolve through here so the precedence stays consistent.
///
/// Edition entitlement floor (<c>FeatureEntitlementCatalog</c>): Enterprise raises the DEFAULT to
/// Edition entitlement floor (<c>FeatureEntitlementCatalog</c>): Pro raises the DEFAULT to
/// at least the floor — it never lowers an admin-raised global default, and an explicit per-tenant
/// override still wins outright (a Global Admin must stay able to throttle a specific tenant
/// below the floor). Precedence: override ?? max(globalDefault, entitlementFloor).
@@ -7,12 +7,12 @@ using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Services
{
/// <summary>
/// Resolves a tenant's effective edition (Community/Enterprise) and its entitlements at read
/// Resolves a tenant's effective edition (Community/Pro) and its entitlements at read
/// time. Rides on <see cref="TenantConfigurationService"/>'s 5-minute config cache — no cache
/// of its own, so plan/trial mutations become visible within the existing staleness budget.
///
/// Fail-closed: any storage/resolution failure yields Community. A broken entitlement lookup
/// must never grant Enterprise capabilities.
/// must never grant Pro capabilities.
/// </summary>
public class TenantEntitlementService
{
@@ -74,6 +74,29 @@ namespace AutopilotMonitor.Functions.Services
public static TenantEdition ResolveEdition(TenantConfiguration config, DateTime nowUtc)
=> FeatureEntitlementCatalog.ResolveEdition(config.PlanTier, config.TrialExpiresUtc, nowUtc);
/// <summary>
/// Whether the OOBE bootstrap feature is effectively enabled for this config: included in
/// the tenant's edition (Pro, incl. active trials) OR explicitly enabled per tenant via
/// the GA-only <see cref="TenantConfiguration.BootstrapTokenEnabled"/> flag (additive —
/// the Community escape hatch). Read-time resolution: a trial expiry or downgrade turns
/// the feature off automatically.
/// </summary>
public static bool IsBootstrapEnabled(TenantConfiguration config, DateTime nowUtc)
=> FeatureEntitlementCatalog.Get(ResolveEdition(config, nowUtc)).BootstrapIncluded
|| config.BootstrapTokenEnabled;
/// <summary>
/// Whether Unrestricted Mode is effectively ACTIVE for this config. Requires all three:
/// the edition allows it (Pro — read-time, so trial expiry / downgrade re-arms the
/// guardrails fail-closed), the GA-only on-request gate
/// (<see cref="TenantConfiguration.UnrestrictedModeEnabled"/>), and the tenant-admin
/// opt-in toggle (<see cref="TenantConfiguration.UnrestrictedMode"/>).
/// </summary>
public static bool IsUnrestrictedModeActive(TenantConfiguration config, DateTime nowUtc)
=> FeatureEntitlementCatalog.Get(ResolveEdition(config, nowUtc)).UnrestrictedModeAvailable
&& config.UnrestrictedModeEnabled
&& config.UnrestrictedMode;
/// <summary>
/// The retention days the platform actually enforces for this config: the stored value
/// clamped to the edition's cap. <c>days &lt;= 0</c> is the GA-only "infinite" escape hatch
@@ -110,21 +110,21 @@ namespace AutopilotMonitor.Shared.Models
/// <summary>
/// Tenant plan tier. Determines default API rate limits and feature gates.
/// Write-side values: "community", "enterprise". Legacy stored values ("free", "pro")
/// remain readable and resolve to Community (fail-closed — see FeatureEntitlementCatalog).
/// Managed by Global Admins.
/// Write-side values: "community", "pro". The legacy stored values "enterprise" (resolves
/// to Pro) and "free" (resolves to Community) remain readable — see
/// FeatureEntitlementCatalog. Managed by Global Admins.
/// </summary>
public string PlanTier { get; set; } = "free";
/// <summary>
/// End of the tenant's Enterprise trial (UTC). While this is in the future the tenant's
/// effective edition is Enterprise regardless of <see cref="PlanTier"/>. Null = no trial.
/// End of the tenant's Pro trial (UTC). While this is in the future the tenant's
/// effective edition is Pro regardless of <see cref="PlanTier"/>. Null = no trial.
/// Expiry degrades the tenant to Community at read time — no timer involved.
/// </summary>
public DateTime? TrialExpiresUtc { get; set; }
/// <summary>
/// When the tenant's Enterprise trial was started (UTC). Informational/audit only.
/// When the tenant's Pro trial was started (UTC). Informational/audit only.
/// </summary>
public DateTime? TrialStartedUtc { get; set; }
@@ -460,27 +460,30 @@ namespace AutopilotMonitor.Shared.Models
// ===== BOOTSTRAP TOKEN =====
/// <summary>
/// Whether OOBE Bootstrap Sessions are enabled for this tenant.
/// When false (default), the Bootstrap Sessions feature is hidden in the UI
/// and all bootstrap API endpoints reject requests for this tenant.
/// Only configurable by Global Admins.
/// Per-tenant ADDITIVE enable for OOBE Bootstrap Sessions (Global Admin only). The Pro
/// plan includes the feature regardless of this flag; for Community tenants this is the
/// on-request escape hatch. Effective value = plan-included OR this flag — resolved via
/// TenantEntitlementService.IsBootstrapEnabled (backend); when effectively false, the
/// feature is hidden in the UI and all bootstrap API endpoints reject requests.
/// </summary>
public bool BootstrapTokenEnabled { get; set; } = false;
// ===== UNRESTRICTED MODE =====
/// <summary>
/// Whether the Unrestricted Mode feature is available for this tenant.
/// When false (default), the Unrestricted Mode section is hidden in the tenant settings UI
/// and UnrestrictedMode cannot be activated by tenant admins.
/// Whether the Unrestricted Mode feature is available for this tenant (the Pro-plan
/// on-request gate). When false (default), the Unrestricted Mode section is hidden in the
/// tenant settings UI and UnrestrictedMode cannot be activated by tenant admins.
/// Only configurable by Global Admins.
/// </summary>
public bool UnrestrictedModeEnabled { get; set; } = false;
/// <summary>
/// When enabled, agent guardrails are relaxed: all registry paths, WMI queries, and commands
/// When effective, agent guardrails are relaxed: all registry paths, WMI queries, and commands
/// are allowed via GatherRules. File paths and diagnostics paths are allowed except C:\Users.
/// Default: false. Can only be toggled by tenant admins when UnrestrictedModeEnabled is true.
/// EFFECTIVE only while the tenant's edition is Pro (read-time re-gate, fail-closed on
/// trial expiry/downgrade) — see TenantEntitlementService.IsUnrestrictedModeActive.
/// </summary>
public bool UnrestrictedMode { get; set; } = false;
@@ -31,7 +31,7 @@ export interface TenantConfiguration {
dataRetentionDays: number;
sessionTimeoutHours: number;
planTier?: string;
/** Enterprise-trial end (ISO, UTC). Null/undefined = no trial. Managed via PATCH plan. */
/** Pro-trial end (ISO, UTC). Null/undefined = no trial. Managed via PATCH plan. */
trialExpiresUtc?: string | null;
/** Whether the tenant has used its one self-service trial. */
trialConsumed?: boolean;
@@ -190,9 +190,9 @@ function TenantManagementSectionInner({
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
// Legacy stored tiers (free/pro) resolve to Community server-side; the select only
// offers the two canonical values, so normalize on save.
planTier: tenant.planTier === "enterprise" ? "enterprise" : "community",
// The select only offers the two canonical write values (community/pro); legacy stored
// "enterprise" normalizes to "pro" on save, legacy "free" to "community".
planTier: tenant.planTier === "pro" || tenant.planTier === "enterprise" ? "pro" : "community",
trialExpiresUtc: tenant.trialExpiresUtc ?? null,
}),
});
@@ -598,17 +598,17 @@ function TenantManagementSectionInner({
<div className="flex items-center justify-between mb-3">
<h3 className="font-semibold text-purple-900">Plan &amp; Trial</h3>
{(() => {
const isEnterpriseTier = editingTenant.planTier === "enterprise";
const isProTier = editingTenant.planTier === "pro" || editingTenant.planTier === "enterprise";
const trialActive = !!editingTenant.trialExpiresUtc &&
new Date(editingTenant.trialExpiresUtc).getTime() > nowMs;
const effective = isEnterpriseTier || trialActive ? "Enterprise" : "Community";
const effective = isProTier || trialActive ? "Pro" : "Community";
return (
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
effective === "Enterprise"
effective === "Pro"
? "bg-purple-100 text-purple-800"
: "bg-gray-100 text-gray-700"
}`}>
Effective: {effective}{!isEnterpriseTier && trialActive ? " (Trial)" : ""}
Effective: {effective}{!isProTier && trialActive ? " (Trial)" : ""}
</span>
);
})()}
@@ -617,14 +617,19 @@ function TenantManagementSectionInner({
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Plan Tier</label>
<select
value={editingTenant.planTier === "enterprise" ? "enterprise" : "community"}
value={editingTenant.planTier === "pro" || editingTenant.planTier === "enterprise" ? "pro" : "community"}
onChange={(e) => setEditingTenant({ ...editingTenant, planTier: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
<option value="community">Community</option>
<option value="enterprise">Enterprise</option>
<option value="pro">Pro</option>
</select>
{editingTenant.planTier && editingTenant.planTier !== "enterprise" && editingTenant.planTier !== "community" && (
{editingTenant.planTier === "enterprise" && (
<p className="text-xs text-amber-600 mt-1">
Stored legacy tier &quot;enterprise&quot; resolves to Pro. Saving normalizes it to &quot;pro&quot;.
</p>
)}
{editingTenant.planTier && !["pro", "enterprise", "community"].includes(editingTenant.planTier) && (
<p className="text-xs text-amber-600 mt-1">
Stored legacy tier &quot;{editingTenant.planTier}&quot; resolves to Community. Saving normalizes it.
</p>
@@ -653,7 +658,7 @@ function TenantManagementSectionInner({
)}
</div>
<p className="text-xs text-gray-500 mt-1">
Set a date to grant/extend an Enterprise trial; clear to end it. Saving does not reset trial consumption.
Set a date to grant/extend a Pro trial; clear to end it. Saving does not reset trial consumption.
</p>
</div>
<div className="flex items-center justify-between">
@@ -821,7 +826,7 @@ function TenantManagementSectionInner({
) : (editingTenant.dataRetentionDays < 7 || editingTenant.dataRetentionDays > 365) ? (
<p className="text-xs text-amber-600 mt-1 font-medium">⚠ Outside tenant range (7–365) — field will be locked for tenant admins</p>
) : (
<p className="text-xs text-gray-400 mt-1">Tenant range: 7–90 (Community) / 7–365 (Enterprise). Values above the plan cap are enforced at the cap. Set 0 for infinite retention (Global only).</p>
<p className="text-xs text-gray-400 mt-1">Tenant range: 7–90 (Community) / 7–365 (Pro). Values above the plan cap are enforced at the cap. Set 0 for infinite retention (Global only).</p>
)}
</div>
@@ -202,7 +202,7 @@ export function SectionUsagePlans() {
type="text"
value={tier.name}
onChange={(e) => updateTier(index, "name", e.target.value)}
placeholder="e.g., free, pro, enterprise"
placeholder="e.g., community, pro"
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
/>
</div>
@@ -108,7 +108,7 @@ export default function GetStartedPage() {
rel="noopener noreferrer"
className="text-[var(--lp-accent-ink)] hover:opacity-80 underline"
>
Enterprise plan
Pro plan
</a>{" "}
adds SLAs, support commitments, MSP delegation, higher operating limits and more —
same service, nothing to migrate.
@@ -2,7 +2,7 @@ import { LandingNavbar } from "../../components/landing/LandingNavbar";
import { SiteFooter } from "../../components/SiteFooter";
import { DOCS_URL } from "@/utils/config";
const LAST_UPDATED = "29 July 2026";
const LAST_UPDATED = "31 July 2026";
const DOCS_SECURITY_FAQ = `${DOCS_URL}/trust/security-faq`;
const DOCS_DATA_FLOWS = `${DOCS_URL}/trust/data-flows`;
@@ -60,7 +60,7 @@ export default function PrivacyPage() {
</p>
<p className="text-gray-700">
A <strong>data processing agreement (DPA / AVV) is available on request</strong>, concluded with
glueckkanja AG. On the Enterprise plan it forms part of the written agreement.
glueckkanja AG. On the Pro plan it forms part of the written agreement.
</p>
<h2 className="text-lg font-semibold text-gray-900 mt-6">What We Collect</h2>
@@ -133,7 +133,7 @@ export default function PrivacyPage() {
<li><strong>Delegated (MSP) administrators you or your provider have been granted</strong> — read-only, limited to exactly the tenants in scope, with configuration secrets redacted, and with every grant and revocation written to <em>your</em> tenant&apos;s audit log so you can always see who was given access to your data</li>
</ul>
<p className="text-gray-700">
Delegated administration is an Enterprise capability and is off unless explicitly granted. Full detail on the
Delegated administration is a Pro capability and is off unless explicitly granted. Full detail on the
isolation model is in the <DocsLink href={DOCS_SECURITY_FAQ}>Security &amp; Privacy FAQ</DocsLink>.
</p>
@@ -175,7 +175,7 @@ export default function PrivacyPage() {
<h2 className="text-lg font-semibold text-gray-900 mt-6">How Long We Keep Data</h2>
<p className="text-gray-700">
Retention is <strong>configured by you per tenant, defaulting to 90 days</strong> — 7 to 90 days on the
Community plan and 7 to 365 days on Enterprise. Expired sessions are purged automatically. You additionally
Community plan and 7 to 365 days on Pro. Expired sessions are purged automatically. You additionally
control:
</p>
<ul className="list-disc list-inside space-y-2 text-gray-700 ml-4">
@@ -111,7 +111,7 @@ interface TenantConfigContextValue {
// Edition / trial (read-time server resolution via feature-flags; fail-closed Community)
editionInfo: EditionInfo;
startingTrial: boolean;
/** Self-service 30-day Enterprise trial (once per tenant). Returns success. */
/** Self-service 30-day Pro trial (once per tenant). Returns success. */
startTrial: () => Promise<boolean>;
// Validation
@@ -641,9 +641,12 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
}, [tenantId, getAccessToken]);
useEffect(() => {
if (!tenantId || !config?.bootstrapTokenEnabled) return;
// Effective availability (mirrors backend IsBootstrapEnabled): Pro plan includes bootstrap;
// the per-tenant GA flag is the additive Community enable.
const bootstrapAvailable = editionInfo.edition === "pro" || config?.bootstrapTokenEnabled;
if (!tenantId || !bootstrapAvailable) return;
fetchBootstrapSessions();
}, [tenantId, config?.bootstrapTokenEnabled, fetchBootstrapSessions]);
}, [tenantId, editionInfo.edition, config?.bootstrapTokenEnabled, fetchBootstrapSessions]);
// -----------------------------------------------------------------------
// Fetch global diagnostics paths (global-admin only)
@@ -1472,7 +1475,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
}, [logout]);
// -----------------------------------------------------------------------
// Self-service Enterprise trial (once per tenant — backend enforces via 409)
// Self-service Pro trial (once per tenant — backend enforces via 409)
// -----------------------------------------------------------------------
const startTrial = useCallback(async (): Promise<boolean> => {
if (!tenantId) return false;
@@ -1494,9 +1497,9 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
if (flagsResponse.ok) {
setEditionInfo(parseEditionInfo(await flagsResponse.json()));
}
setSuccessMessage("Enterprise trial started — all Enterprise features are now active for 30 days.");
setSuccessMessage("Pro trial started — all Pro features are now active for 30 days.");
setTimeout(() => setSuccessMessage(null), 5000);
trackEvent("EnterpriseTrialStarted", { tenantId });
trackEvent("ProTrialStarted", { tenantId });
return true;
} catch (err) {
if (err instanceof TokenExpiredError) {
@@ -6,7 +6,7 @@ import UnrestrictedModeSection from "../../components/UnrestrictedModeSection";
export function SectionUnrestrictedMode() {
const {
canEditConfig, config,
canEditConfig, config, editionInfo,
unrestrictedMode, setUnrestrictedMode,
handleSaveUnrestrictedMode,
savingSection,
@@ -22,10 +22,15 @@ export function SectionUnrestrictedMode() {
);
}
if (!config?.unrestrictedModeEnabled) {
// Pro-plan feature, activated on request: requires the effective Pro edition AND the GA-set
// gate (mirrors the backend's read-time re-gate — a downgraded tenant loses the section).
if (editionInfo.edition !== "pro" || !config?.unrestrictedModeEnabled) {
return (
<div className="bg-white rounded-lg shadow p-8 text-center">
<p className="text-gray-500">Unrestricted Mode is not available for this tenant.</p>
<p className="text-gray-500">
Unrestricted Mode is not available for this tenant. It is a Pro-plan feature and is
activated on request.
</p>
</div>
);
}
@@ -9,7 +9,7 @@ interface DataManagementSectionProps {
sessionTimeoutHours: number;
setSessionTimeoutHours: (value: number) => void;
isGlobalAdmin?: boolean;
/** Edition retention cap (Community 90 / Enterprise 365) — from feature-flags entitlements. */
/** Edition retention cap (Community 90 / Pro 365) — from feature-flags entitlements. */
retentionCapDays?: number;
onSave: () => Promise<void> | void;
onReset: () => void;
@@ -82,7 +82,7 @@ export default function DataManagementSection({
</div>
) : (
<p className="text-xs text-gray-400 mt-1">
Minimum: 7 days, Maximum: {retentionCapDays} days{retentionCapDays < 365 ? " (up to 365 with Enterprise)" : ""}
Minimum: 7 days, Maximum: {retentionCapDays} days{retentionCapDays < 365 ? " (up to 365 with Pro)" : ""}
</p>
)}
</label>
@@ -6,36 +6,36 @@ import { useTenantConfig } from "../TenantConfigContext";
/**
* Edition badge for the settings header.
* - "Enterprise" / "Enterprise Trial — X days left" / "Community"
* - "Pro" / "Pro Trial — X days left" / "Community"
* - For Community tenants, an "Upgrade plan" link to the Plan section, where the full picture
* (what Enterprise adds) and the trial CTA live. The badge itself only reports status.
* (what Pro adds) and the trial CTA live. The badge itself only reports status.
*/
export default function EditionBadge() {
const { editionInfo } = useTenantConfig();
const isEnterprise = editionInfo.edition === "enterprise";
const isPro = editionInfo.edition === "pro";
const label = editionLabel(editionInfo);
return (
<div className="flex items-center gap-3">
<span
className={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold ${
isEnterprise
isPro
? "bg-purple-100 text-purple-800 border border-purple-300"
: "bg-gray-100 text-gray-700 border border-gray-300"
}`}
title={
isEnterprise
isPro
? editionInfo.isTrial
? "Enterprise trial is active — all Enterprise features are unlocked."
: "This tenant is on the Enterprise plan."
? "Pro trial is active — all Pro features are unlocked."
: "This tenant is on the Pro plan."
: "This tenant is on the Community plan."
}
>
{label}
</span>
{!isEnterprise && (
{!isPro && (
<Link
href="/settings/tenant/plan"
className="inline-flex items-center gap-1 text-xs font-medium text-purple-700 border border-purple-300 rounded-full px-3 py-1 hover:bg-purple-50 transition-colors"
@@ -6,12 +6,14 @@ import BootstrapSessionsSection from "../../components/BootstrapSessionsSection"
export function SectionBootstrapSessions() {
const {
config,
config, editionInfo,
bootstrapSessions, bootstrapLoading,
fetchBootstrapSessions, revokeBootstrapSession, createBootstrapSession,
} = useTenantConfig();
if (!config?.bootstrapTokenEnabled) {
// Effective availability: included in the Pro plan, or enabled per tenant via the GA flag
// (mirrors the backend's TenantEntitlementService.IsBootstrapEnabled).
if (editionInfo.edition !== "pro" && !config?.bootstrapTokenEnabled) {
return (
<div className="bg-white rounded-lg shadow p-8 text-center">
<p className="text-gray-500">Bootstrap Sessions are not available for this tenant.</p>
@@ -5,7 +5,7 @@ import { useTenantConfig } from "../../TenantConfigContext";
import { trialDaysLeft } from "@/lib/edition";
/**
* Self-service Enterprise trial switch. The Enterprise feature set is not finalized yet, so the
* Self-service Pro trial switch. The Pro feature set is not finalized yet, so the
* trial CTA is teased but not actionable. Flip this to `true` (a one-line change) to open the
* self-service 30-day trial — the backend POST /trial endpoint and the startTrial() wiring are
* already in place; only this gate keeps the button inert.
@@ -15,10 +15,10 @@ const TRIAL_SELF_SERVICE_ENABLED = false;
/**
* Retention baselines per plan tier. These describe the two PLANS on the comparison cards, so they
* must not depend on the viewer's current edition — they mirror the backend catalog
* (FeatureEntitlementCatalog: Community RetentionCapDays = 90, Enterprise = 365).
* (FeatureEntitlementCatalog: Community RetentionCapDays = 90, Pro = 365).
*/
const COMMUNITY_RETENTION_DAYS = 90;
const ENTERPRISE_RETENTION_DAYS = 365;
const PRO_RETENTION_DAYS = 365;
function CheckIcon({ className }: { className: string }) {
return (
@@ -49,7 +49,7 @@ function FeatureList({ features, checkClass, muted = false }: { features: string
);
}
/** The Enterprise delta: same layout as FeatureList but "+" bullets and emphasized text. */
/** The Pro delta: same layout as FeatureList but "+" bullets and emphasized text. */
function PlusList({ features }: { features: string[] }) {
return (
<ul className="space-y-2.5">
@@ -64,8 +64,8 @@ function PlusList({ features }: { features: string[] }) {
}
/**
* Plan section: two side-by-side plan cards (Community and Enterprise). The tenant's current plan
* is highlighted so the edition is obvious at a glance; the Enterprise card teases what it adds.
* Plan section: two side-by-side plan cards (Community and Pro). The tenant's current plan
* is highlighted so the edition is obvious at a glance; the Pro card teases what it adds.
*
* Dark mode: the tinted card surfaces use slash-opacity utilities (bg-purple-50/40), which the
* global `.dark .bg-*` override map in globals.css does NOT match — they are separate class tokens.
@@ -77,15 +77,15 @@ export function SectionPlan() {
const { editionInfo, startTrial, startingTrial, user } = useTenantConfig();
const [confirming, setConfirming] = useState(false);
const isEnterprise = editionInfo.edition === "enterprise";
const isPro = editionInfo.edition === "pro";
const daysLeft = editionInfo.isTrial ? trialDaysLeft(editionInfo.trialExpiresUtc) : 0;
const trialConsumed = !isEnterprise && !editionInfo.trialAvailable;
const trialConsumed = !isPro && !editionInfo.trialAvailable;
const canStartTrial =
editionInfo.trialAvailable && (user?.isTenantAdmin === true || user?.isGlobalAdmin === true);
// Features shared by both plans verbatim. Plan-specific items (retention, support tier) are NOT
// in here — repeating "90-day retention" on the Enterprise card would be factually wrong; their
// Enterprise counterparts live in enterpriseExtras instead.
// in here — repeating "90-day retention" on the Pro card would be factually wrong; their
// Pro counterparts live in proExtras instead.
const sharedFeatures = [
"Live session monitoring & progress portal",
"Full rules engine, including custom rules",
@@ -99,11 +99,13 @@ export function SectionPlan() {
"Community support (GitHub)",
];
const enterpriseExtras = [
`Extended data retention — ${ENTERPRISE_RETENTION_DAYS} days (vs ${COMMUNITY_RETENTION_DAYS})`,
const proExtras = [
`Extended data retention — ${PRO_RETENTION_DAYS} days (vs ${COMMUNITY_RETENTION_DAYS})`,
"Higher portal & agent API rate limits",
"Larger AI (MCP) usage quota",
"Delegated (MSP) administration across tenants",
"OOBE bootstrap sessions — test the agent without an Intune deployment",
"Unrestricted Mode for advanced data collection (activated on request)",
"Reliability commitments & priority support",
];
@@ -116,7 +118,7 @@ export function SectionPlan() {
</svg>
<div>
<h2 className="text-xl font-semibold text-gray-900">Plan</h2>
<p className="text-sm text-gray-500 mt-1">Your current plan and what Enterprise adds</p>
<p className="text-sm text-gray-500 mt-1">Your current plan and what Pro adds</p>
</div>
</div>
</div>
@@ -126,7 +128,7 @@ export function SectionPlan() {
{/* Community card */}
<div
className={`rounded-xl border p-6 flex flex-col ${
!isEnterprise
!isPro
? "border-gray-800 ring-1 ring-gray-800 bg-gray-50/60 dark:border-slate-500 dark:ring-slate-500 dark:bg-slate-900/40"
: "border-gray-200"
}`}
@@ -136,7 +138,7 @@ export function SectionPlan() {
<h3 className="text-lg font-semibold text-gray-900">Community</h3>
<p className="text-sm text-gray-600 mt-0.5">The full product, free</p>
</div>
{!isEnterprise && (
{!isPro && (
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-gray-900 text-white dark:bg-slate-200 dark:text-slate-900">
Current plan
</span>
@@ -151,20 +153,20 @@ export function SectionPlan() {
<FeatureList features={communityFeatures} checkClass="text-emerald-500" />
</div>
{/* Enterprise card */}
{/* Pro card */}
<div
className={`rounded-xl border p-6 flex flex-col ${
isEnterprise
isPro
? "border-purple-500 ring-1 ring-purple-500 bg-purple-50/40 dark:bg-purple-950/40"
: "border-purple-200 bg-purple-50/20 dark:bg-purple-950/20"
}`}
>
<div className="flex items-start justify-between gap-2">
<div>
<h3 className="text-lg font-semibold text-purple-900">Enterprise</h3>
<h3 className="text-lg font-semibold text-purple-900">Pro</h3>
<p className="text-sm text-gray-600 mt-0.5">Higher limits, support & MSP</p>
</div>
{isEnterprise ? (
{isPro ? (
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-purple-600 text-white">
{editionInfo.isTrial ? `Trial — ${daysLeft} day${daysLeft === 1 ? "" : "s"} left` : "Current plan"}
</span>
@@ -177,9 +179,9 @@ export function SectionPlan() {
<div className="mt-4 mb-5">
<span className="text-2xl font-bold text-purple-900">
{isEnterprise ? "Active" : "Pricing TBA"}
{isPro ? "Active" : "Pricing TBA"}
</span>
{!isEnterprise && <span className="text-sm text-gray-600"> — to be announced</span>}
{!isPro && <span className="text-sm text-gray-600"> — to be announced</span>}
</div>
<p className="text-xs font-medium uppercase tracking-wide text-gray-600 mb-2.5">
@@ -192,14 +194,14 @@ export function SectionPlan() {
<span className="text-xs font-semibold uppercase tracking-wide text-purple-600 dark:text-purple-300">Plus</span>
<span className="h-px flex-1 bg-purple-200 dark:bg-purple-800" />
</div>
<PlusList features={enterpriseExtras} />
<PlusList features={proExtras} />
{/* CTA — only meaningful while the tenant is on Community */}
{!isEnterprise && (
{!isPro && (
<div className="mt-auto pt-5">
{trialConsumed ? (
<p className="text-sm text-gray-600">
This tenant has already used its Enterprise trial. To move to Enterprise,{" "}
This tenant has already used its Pro trial. To move to Pro,{" "}
<a
href="https://github.com/okieselbach/AutopilotMonitor/issues"
target="_blank"
@@ -214,10 +216,10 @@ export function SectionPlan() {
<button
type="button"
disabled
title="Available soon — the Enterprise trial opens once the feature set is finalized."
title="Available soon — the Pro trial opens once the feature set is finalized."
className="w-full text-sm font-medium text-white bg-purple-400 rounded-lg px-4 py-2.5 cursor-not-allowed opacity-70"
>
Start 30-day Enterprise trial — coming soon
Start 30-day Pro trial — coming soon
</button>
) : canStartTrial && !confirming ? (
<button
@@ -225,7 +227,7 @@ export function SectionPlan() {
onClick={() => setConfirming(true)}
className="w-full text-sm font-medium text-white bg-purple-600 rounded-lg px-4 py-2.5 hover:bg-purple-700 transition-colors"
>
Start 30-day Enterprise trial
Start 30-day Pro trial
</button>
) : canStartTrial && confirming ? (
<div className="flex items-center gap-2 text-sm">
@@ -257,9 +259,9 @@ export function SectionPlan() {
</div>
<p className="text-xs text-gray-600 mt-5">
{isEnterprise && editionInfo.isTrial
{isPro && editionInfo.isTrial
? "When the trial ends, the tenant returns to Community automatically."
: "Scope, pricing and timeline for Enterprise will be announced. Community stays free."}
: "Scope, pricing and timeline for Pro will be announced. Community stays free."}
</p>
</div>
</div>
@@ -7,7 +7,7 @@ export const TENANT_NAV_SECTIONS = [
{ id: "bootstrap-sessions", label: "Bootstrap Sessions", description: "Create and manage bootstrap tokens" },
{ id: "graph-permissions", label: "Optional Graph capabilities", description: "Grant additional Microsoft Graph permissions for optional features" },
{ id: "support", label: "Submit Logs", description: "Send diagnostic files to the Autopilot Monitor team" },
{ id: "plan", label: "Plan", description: "Your current plan and what Enterprise adds" },
{ id: "plan", label: "Plan", description: "Your current plan and what Pro adds" },
{ id: "contact", label: "Contact", description: "Where we reach you about the service" },
] as const;
@@ -2,7 +2,7 @@ import { LandingNavbar } from "../../components/landing/LandingNavbar";
import { SiteFooter } from "../../components/SiteFooter";
import { DOCS_URL } from "@/utils/config";
const LAST_UPDATED = "21 July 2026";
const LAST_UPDATED = "31 July 2026";
const DOCS_SECURITY_FAQ = `${DOCS_URL}/trust/security-faq`;
const DOCS_PLANS = `${DOCS_URL}/plans`;
const GITHUB_ADVISORY = "https://github.com/okieselbach/AutopilotMonitor/security/advisories/new";
@@ -44,7 +44,7 @@ export default function TermsPage() {
</p>
</div>
<div className="p-4 border border-[var(--lp-line-soft)] rounded-lg">
<h3 className="font-semibold text-gray-900 mb-1">Enterprise</h3>
<h3 className="font-semibold text-gray-900 mb-1">Pro</h3>
<p className="text-sm text-gray-700">
Commercial plan under a written agreement with glueckkanja AG. Includes support and reliability
commitments, a data processing agreement, higher operating limits, extended retention, and delegated
@@ -62,7 +62,7 @@ export default function TermsPage() {
<strong>Who can use it.</strong> Autopilot Monitor requires a Microsoft Entra ID tenant with Intune and
registered Autopilot devices. The <strong>Community</strong> plan is open to anyone with such a tenant —
organizations, non-profits, and individuals running their own lab or personal tenant alike. The{" "}
<strong>Enterprise</strong> plan is a commercial agreement and is offered to businesses and organizations
<strong>Pro</strong> plan is a commercial agreement and is offered to businesses and organizations
acting in a commercial or professional capacity.
</p>
</div>
@@ -111,7 +111,7 @@ export default function TermsPage() {
<h2 className="text-lg font-semibold text-gray-900">Delegated (MSP) Administration</h2>
<p className="text-gray-700">
Delegated administration lets a managing organization see a defined set of customer tenants from one place. It
is an Enterprise capability and applies only where access has been granted.
is a Pro capability and applies only where access has been granted.
</p>
<ul className="list-disc list-inside space-y-2 text-gray-700 ml-4">
<li>Delegated access is <strong>read-only</strong> and limited to exactly the tenants in scope. Configuration secrets are redacted.</li>
@@ -136,10 +136,10 @@ export default function TermsPage() {
</div>
<div className="space-y-2 text-gray-700">
<p><strong>Availability.</strong> The Community plan carries no uptime or availability commitment; interruptions, maintenance, and changes can occur without prior notice. The Enterprise plan carries the availability commitments set out in its agreement.</p>
<p><strong>Support.</strong> Community support is best-effort via <a href="https://github.com/okieselbach/AutopilotMonitor/issues" target="_blank" rel="noopener noreferrer" className="text-[var(--lp-accent-ink)] hover:opacity-80 underline">GitHub issues</a>, provided by the project maintainer and the community, with no guaranteed response or resolution time; built-in rules and IME log patterns are community-maintained. Enterprise support follows the response commitments in its agreement.</p>
<p><strong>Availability.</strong> The Community plan carries no uptime or availability commitment; interruptions, maintenance, and changes can occur without prior notice. The Pro plan carries the availability commitments set out in its agreement.</p>
<p><strong>Support.</strong> Community support is best-effort via <a href="https://github.com/okieselbach/AutopilotMonitor/issues" target="_blank" rel="noopener noreferrer" className="text-[var(--lp-accent-ink)] hover:opacity-80 underline">GitHub issues</a>, provided by the project maintainer and the community, with no guaranteed response or resolution time; built-in rules and IME log patterns are community-maintained. Pro support follows the response commitments in its agreement.</p>
<p><strong>Data durability.</strong> Autopilot Monitor is a monitoring system, not a system of record. Configuration, authorization, and rule data is backed up daily; session and event telemetry is time-bounded operational data and is <strong>not</strong> backed up. Retain anything you need for compliance or reporting purposes in your own systems.</p>
<p><strong>Liability.</strong> To the extent permitted by law, glueckkanja AG is not liable for indirect, incidental, special, consequential, or punitive damages, or for loss of data, profit, or business, arising from use of or inability to use the service. For the Community plan, which is provided free of charge, liability is limited to intent and gross negligence. Liability for injury to life, body or health and under mandatory statutory provisions remains unaffected. For the Enterprise plan, the liability provisions of the written agreement apply.</p>
<p><strong>Liability.</strong> To the extent permitted by law, glueckkanja AG is not liable for indirect, incidental, special, consequential, or punitive damages, or for loss of data, profit, or business, arising from use of or inability to use the service. For the Community plan, which is provided free of charge, liability is limited to intent and gross negligence. Liability for injury to life, body or health and under mandatory statutory provisions remains unaffected. For the Pro plan, the liability provisions of the written agreement apply.</p>
<p><strong>Use at your own risk.</strong> The service reports on enrollments; it does not perform them. Operational decisions you take based on its output remain your responsibility.</p>
</div>
</div>
@@ -151,7 +151,7 @@ export default function TermsPage() {
<p><strong>Your controls.</strong> You set the retention period, delete individual sessions, and offboard your tenant entirely at any time — no support ticket required.</p>
<p><strong>Rules you contribute.</strong> Analyze rules, gather rules, and IME log patterns you author are detection definitions, not device data. By creating them you grant a non-exclusive, royalty-free right to retain them and to include them in the shared community rule pool, so that other organizations can benefit from a detection you built. This is the reciprocal side of a product whose built-in rules are community-maintained. You keep the right to use your own rules however you like, and you can request removal from the pool at any time.</p>
<p><strong>Suspension.</strong> Access may be suspended where use threatens the integrity, security, or availability of the service or of other tenants — for example abusive request volumes or attempts to reach other tenants&apos; data. Where circumstances permit, notice is given first.</p>
<p><strong>Termination.</strong> You may stop using the service and offboard at any time. For the Community plan, the operator may discontinue the service or an individual tenant&apos;s access; reasonable advance notice will be given except where security requires immediate action. Enterprise termination follows its agreement.</p>
<p><strong>Termination.</strong> You may stop using the service and offboard at any time. For the Community plan, the operator may discontinue the service or an individual tenant&apos;s access; reasonable advance notice will be given except where security requires immediate action. Pro termination follows its agreement.</p>
<p><strong>After offboarding.</strong> Your tenant&apos;s data is removed through a verified multi-phase cascade. Product feedback you submitted, and custom rules and IME log patterns you authored, are intentionally retained — neither contains enrollment telemetry or personal data. Both are removed on request.</p>
</div>
</div>
@@ -162,7 +162,7 @@ export default function TermsPage() {
The security architecture, data residency, external services, retention and deletion behaviour, and an explicit
statement of what the service does <em>not</em> do are published in the{" "}
<a href={DOCS_SECURITY_FAQ} target="_blank" rel="noopener noreferrer" className="text-[var(--lp-accent-ink)] hover:opacity-80 underline">Security &amp; Privacy FAQ</a>.
A signed data processing agreement is part of the Enterprise plan.
A signed data processing agreement is part of the Pro plan.
</p>
</div>
@@ -205,7 +205,7 @@ export default function TermsPage() {
<h2 className="text-lg font-semibold text-gray-900">Changes and Governing Law</h2>
<div className="space-y-2 text-gray-700">
<p><strong>Changes.</strong> These terms may be updated; the &quot;last updated&quot; date above reflects the current version, and material changes are announced through the service announcements in the portal. Continued use after a change constitutes acceptance.</p>
<p><strong>Governing law.</strong> These terms are governed by German law. For the Enterprise plan, the governing-law and venue provisions of the written agreement apply.</p>
<p><strong>Governing law.</strong> These terms are governed by German law. For the Pro plan, the governing-law and venue provisions of the written agreement apply.</p>
<p><strong>Severability.</strong> If a provision is found unenforceable, the remaining provisions stay in effect.</p>
<p><strong>Contact.</strong> Company details are in the <a href="https://www.glueckkanja.com/en/imprint" target="_blank" rel="noopener noreferrer" className="text-[var(--lp-accent-ink)] hover:opacity-80 underline">Imprint</a>. For the project and the Community edition, reach the maintainer via <a href="https://www.linkedin.com/in/oliver-kieselbach" target="_blank" rel="noopener noreferrer" className="text-[var(--lp-accent-ink)] hover:opacity-80 underline">LinkedIn</a> or open a <a href="https://github.com/okieselbach/AutopilotMonitor/issues" target="_blank" rel="noopener noreferrer" className="text-[var(--lp-accent-ink)] hover:opacity-80 underline">GitHub issue</a>.</p>
</div>
@@ -24,7 +24,7 @@ export function FinalCta() {
rel="noopener noreferrer"
className="text-[var(--lp-accent-ink)] hover:opacity-80 underline"
>
There&apos;s an Enterprise plan
There&apos;s a Pro plan
</a>
.
</span>
@@ -9,9 +9,9 @@ import {
const NOW = new Date("2026-07-07T12:00:00Z");
describe("parseEditionInfo", () => {
it("parses a full enterprise-trial payload", () => {
it("parses a full pro-trial payload", () => {
const info = parseEditionInfo({
edition: "enterprise",
edition: "pro",
isTrial: true,
trialExpiresUtc: "2026-07-20T12:00:00Z",
trialAvailable: false,
@@ -19,11 +19,11 @@ describe("parseEditionInfo", () => {
retentionCapDays: 365,
userRateLimitPerMinute: 150,
delegatedAdminAllowed: true,
mcpUsagePlan: "enterprise",
mcpUsagePlan: "pro",
},
});
expect(info.edition).toBe("enterprise");
expect(info.edition).toBe("pro");
expect(info.isTrial).toBe(true);
expect(info.trialExpiresUtc).toBe("2026-07-20T12:00:00Z");
expect(info.entitlements.retentionCapDays).toBe(365);
@@ -31,6 +31,11 @@ describe("parseEditionInfo", () => {
expect(info.entitlements.delegatedAdminAllowed).toBe(true);
});
it("accepts the pre-rename edition value 'enterprise' as pro (deploy-order safety)", () => {
const info = parseEditionInfo({ edition: "enterprise" });
expect(info.edition).toBe("pro");
});
it("fails closed to Community for malformed payloads", () => {
expect(parseEditionInfo(null)).toEqual(COMMUNITY_DEFAULT);
expect(parseEditionInfo(undefined)).toEqual(COMMUNITY_DEFAULT);
@@ -45,7 +50,7 @@ describe("parseEditionInfo", () => {
});
it("defaults missing entitlements to Community values", () => {
const info = parseEditionInfo({ edition: "enterprise" });
const info = parseEditionInfo({ edition: "pro" });
expect(info.entitlements.retentionCapDays).toBe(90);
expect(info.entitlements.userRateLimitPerMinute).toBeNull();
});
@@ -74,24 +79,24 @@ describe("editionLabel", () => {
expect(editionLabel(COMMUNITY_DEFAULT, NOW)).toBe("Community");
});
it("labels permanent enterprise", () => {
const info = parseEditionInfo({ edition: "enterprise", isTrial: false });
expect(editionLabel(info, NOW)).toBe("Enterprise");
it("labels permanent pro", () => {
const info = parseEditionInfo({ edition: "pro", isTrial: false });
expect(editionLabel(info, NOW)).toBe("Pro");
});
it("labels a trial with a day countdown (singular/plural)", () => {
const plural = parseEditionInfo({
edition: "enterprise",
edition: "pro",
isTrial: true,
trialExpiresUtc: "2026-07-10T12:00:00Z",
});
expect(editionLabel(plural, NOW)).toBe("Enterprise Trial — 3 days left");
expect(editionLabel(plural, NOW)).toBe("Pro Trial — 3 days left");
const singular = parseEditionInfo({
edition: "enterprise",
edition: "pro",
isTrial: true,
trialExpiresUtc: "2026-07-07T18:00:00Z",
});
expect(editionLabel(singular, NOW)).toBe("Enterprise Trial — 1 day left");
expect(editionLabel(singular, NOW)).toBe("Pro Trial — 1 day left");
});
});
+1 -1
View File
@@ -111,7 +111,7 @@ export const api = {
featureFlags: (tenantId: string) => `${API_BASE_URL}/api/config/${tenantId}/feature-flags`,
/** PATCH — GA-only plan/trial mutation: { planTier?, trialExpiresUtc?: ISO | null }. */
plan: (tenantId: string) => `${API_BASE_URL}/api/config/${tenantId}/plan`,
/** POST — tenant-admin self-service 30-day Enterprise trial (once per tenant, 409 after). */
/** POST — tenant-admin self-service 30-day Pro trial (once per tenant, 409 after). */
trial: (tenantId: string) => `${API_BASE_URL}/api/config/${tenantId}/trial`,
autopilotConsentUrl: (tenantId: string, redirectUri: string) =>
`${API_BASE_URL}/api/config/${tenantId}/autopilot-device-validation/consent-url${qs({ redirectUri })}`,
+13 -8
View File
@@ -1,11 +1,11 @@
/**
* Pure edition/trial math for the Community/Enterprise entitlement surface.
* Pure edition/trial math for the Community/Pro entitlement surface.
* Mirrors the backend's read-time resolution (FeatureEntitlementCatalog): the UI never
* decides entitlements itself — it renders what /feature-flags reports — but needs small
* pure helpers for countdown/labels. Kept free of React so it is unit-testable.
*/
export type TenantEditionName = "community" | "enterprise";
export type TenantEditionName = "community" | "pro";
/** Edition/trial surface returned by GET /api/config/{tenantId}/feature-flags. */
export interface EditionInfo {
@@ -35,11 +35,16 @@ export const COMMUNITY_DEFAULT: EditionInfo = {
},
};
/** Parse the feature-flags payload's edition surface; malformed → Community default. */
/**
* Parse the feature-flags payload's edition surface; malformed → Community default.
* Accepts both "pro" and the pre-rename value "enterprise" (deploy-order safety: this web
* build may briefly run against a backend that still reports "enterprise").
*/
export function parseEditionInfo(flags: unknown): EditionInfo {
if (!flags || typeof flags !== "object") return COMMUNITY_DEFAULT;
const f = flags as Record<string, unknown>;
const edition: TenantEditionName = f.edition === "enterprise" ? "enterprise" : "community";
const edition: TenantEditionName =
f.edition === "pro" || f.edition === "enterprise" ? "pro" : "community";
const ent = (f.entitlements && typeof f.entitlements === "object"
? (f.entitlements as Record<string, unknown>)
: {}) as Record<string, unknown>;
@@ -71,10 +76,10 @@ export function trialDaysLeft(trialExpiresUtc: string | null | undefined, now: D
return ms <= 0 ? 0 : Math.ceil(ms / 86_400_000);
}
/** Badge label: "Enterprise", "Enterprise Trial — X days left", or "Community". */
/** Badge label: "Pro", "Pro Trial — X days left", or "Community". */
export function editionLabel(info: EditionInfo, now: Date = new Date()): string {
if (info.edition !== "enterprise") return "Community";
if (!info.isTrial) return "Enterprise";
if (info.edition !== "pro") return "Community";
if (!info.isTrial) return "Pro";
const days = trialDaysLeft(info.trialExpiresUtc, now);
return `Enterprise Trial — ${days} day${days === 1 ? "" : "s"} left`;
return `Pro Trial — ${days} day${days === 1 ? "" : "s"} left`;
}