From d8e8cd15fd5839bfcb1bb10f2e2031d15693076b Mon Sep 17 00:00:00 2001
From: Oliver Kieselbach
Date: Tue, 7 Jul 2026 13:06:04 +0200
Subject: [PATCH] feat(entitlements): Community/Enterprise editions with
self-service trial
Fail-closed entitlement layer (FeatureEntitlementCatalog + TenantEntitlementService): edition resolves at read time (PlanTier == 'enterprise' OR active trial; legacy free/pro => Community, no migration).
- Plan/trial endpoints: PATCH config/{tid}/plan (GA: tier + grant/extend/end trial, audited, cache-safe) and POST config/{tid}/trial (tenant-admin self-service 30d, once per tenant, 409 after). Generic PUT preserves plan/trial fields for all callers. Fixes SetPlanTier writing past the config cache without audit/UpdatedBy.
- Rate limits: Enterprise floor 150/min for BOTH user (portal/JWT) and device (agent/cert) paths - floor raises the global default, an explicit per-tenant override still wins; MSP users follow their home tenant.
- Retention cap: Community 90d / Enterprise 365d, clamped in the retention fanout (warn log) + non-GA write validation 7..cap (only on changed values; 0=infinite stays GA-only).
- Delegated admin (MSP): Enterprise gate on the delegated admin's HOME tenant (JWT tid, fail-closed on unknown) - managed target tenants may be any edition. Grant-time target check removed; inert grants are suppressed at resolve time (single choke point in GetScopeAsync, applied after the UPN-keyed cache).
- MCP quota: McpQuotaService + McpQuotaEnforcementMiddleware (X-Client-Source: mcp only, GA exempt) enforce daily/monthly budgets - per-user plan override > tenant edition plan; limits from SectionUsagePlans with catalog fallbacks (community 100/3000, enterprise 1000/20000); 429 with X-MCP-Quota-* headers + Retry-After; fail-open on counter reads, fail-closed on plan resolution. /api/metrics/mcp-usage/me reports effective plan + counters.
- Trial visibility: TrialExpirySweepFunction (daily 03:30 UTC, informational) emits TenantTrialExpired (Warning, 24h look-back) + TenantTrialExpiring (Info, <=3d heads-up); dual-registered in web OPS_EVENT_TYPES.
- Web: EditionBadge + one-time trial CTA in settings header, Plan & Trial card in the GA tenant edit modal (own PATCH save), retention cap hints from entitlements, pure lib/edition.ts (vitest), feature-flags surface edition/trial/entitlements; MCP TENANT_SAFE_FIELDS + trial fields.
Verified: solution build clean; backend 2653/2653 tests; web tsc + 516/516 vitest; MCP server tsc + tests green.
---
.../AuthFunctionSideEffectTests.cs | 6 +-
.../DelegatedAdminEditionGateTests.cs | 131 +++++++++
.../DelegatedAdminServiceTests.cs | 8 +-
.../FeatureEntitlementCatalogTests.cs | 103 +++++++
.../GetTenantFeatureFlagsPayloadTests.cs | 61 +++-
.../McpQuotaServiceTests.cs | 228 ++++++++++++++
.../McpUserServiceDelegatedTests.cs | 8 +-
.../PolicyEnforcementMiddlewareTests.cs | 8 +-
.../RateLimitResolverTests.cs | 59 ++++
.../SessionRetentionFanoutServiceTests.cs | 40 ++-
.../StubTenantEntitlementService.cs | 31 ++
.../TenantConfigProjectionTests.cs | 3 +-
.../TenantConfigTableSerializationTests.cs | 88 ++++++
.../TenantEntitlementServiceTests.cs | 144 +++++++++
.../TestTimeProvider.cs | 16 +
.../TrialExpirySweepFunctionTests.cs | 182 ++++++++++++
.../TableStorage/TableConfigRepository.cs | 16 +-
.../Admin/DelegatedAdminManagementFunction.cs | 5 +
.../Config/GetTenantFeatureFlagsFunction.cs | 57 ++--
.../Config/PlanManagementFunction.cs | 277 +++++++++++++++---
.../UpdateTenantConfigurationFunction.cs | 32 ++
.../Functions/Infrastructure/AuthFunction.cs | 5 +-
.../Infrastructure/McpUserFunction.cs | 3 +-
.../SignalRAddToGroupFunction.cs | 4 +-
.../SignalRRemoveFromGroupFunction.cs | 2 +-
.../Maintenance/TrialExpirySweepFunction.cs | 134 +++++++++
.../Metrics/McpUsageMetricsFunction.cs | 19 +-
.../Helpers/TenantConfigProjection.cs | 2 +
.../McpQuotaEnforcementMiddleware.cs | 125 ++++++++
.../Middleware/PolicyEnforcementMiddleware.cs | 10 +-
.../Middleware/UserRateLimitMiddleware.cs | 20 +-
.../AutopilotMonitor.Functions/Program.cs | 6 +
.../Security/EndpointAccessPolicyCatalog.cs | 3 +
.../Security/FeatureEntitlementCatalog.cs | 119 ++++++++
.../Security/SecurityValidator.cs | 13 +-
.../Services/DelegatedAdminService.cs | 52 +++-
.../Deletion/SessionRetentionFanoutService.cs | 17 +-
.../Services/McpQuotaService.cs | 205 +++++++++++++
.../Services/McpUserService.cs | 7 +-
.../Services/OpsEventService.cs | 26 ++
.../Services/RateLimitResolver.cs | 23 +-
.../Services/TenantEntitlementService.cs | 92 ++++++
.../autopilot-monitor-mcp/src/tools/admin.ts | 3 +-
.../Models/Config/PlanTierDefinitionParser.cs | 31 ++
.../Models/Config/TenantConfiguration.cs | 27 +-
.../admin/components/OpsAlertRulesSection.tsx | 12 +-
.../components/TenantManagementSection.tsx | 145 ++++++++-
.../sections/SectionTenantConfigReport.tsx | 9 +-
.../app/settings/TenantConfigContext.tsx | 67 ++++-
.../components/DataManagementSection.tsx | 13 +-
.../app/settings/components/EditionBadge.tsx | 75 +++++
.../sections/SectionDataManagement.tsx | 3 +-
.../app/settings/tenant/TenantSidebar.tsx | 11 +-
.../app/settings/types.ts | 4 +
.../lib/__tests__/edition.test.ts | 97 ++++++
src/Web/autopilot-monitor-web/lib/api.ts | 4 +
src/Web/autopilot-monitor-web/lib/edition.ts | 80 +++++
57 files changed, 2852 insertions(+), 119 deletions(-)
create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/DelegatedAdminEditionGateTests.cs
create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/FeatureEntitlementCatalogTests.cs
create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/McpQuotaServiceTests.cs
create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/StubTenantEntitlementService.cs
create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigTableSerializationTests.cs
create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/TenantEntitlementServiceTests.cs
create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/TestTimeProvider.cs
create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/TrialExpirySweepFunctionTests.cs
create mode 100644 src/Backend/AutopilotMonitor.Functions/Functions/Maintenance/TrialExpirySweepFunction.cs
create mode 100644 src/Backend/AutopilotMonitor.Functions/Middleware/McpQuotaEnforcementMiddleware.cs
create mode 100644 src/Backend/AutopilotMonitor.Functions/Security/FeatureEntitlementCatalog.cs
create mode 100644 src/Backend/AutopilotMonitor.Functions/Services/McpQuotaService.cs
create mode 100644 src/Backend/AutopilotMonitor.Functions/Services/TenantEntitlementService.cs
create mode 100644 src/Shared/AutopilotMonitor.Shared/Models/Config/PlanTierDefinitionParser.cs
create mode 100644 src/Web/autopilot-monitor-web/app/settings/components/EditionBadge.tsx
create mode 100644 src/Web/autopilot-monitor-web/lib/__tests__/edition.test.ts
create mode 100644 src/Web/autopilot-monitor-web/lib/edition.ts
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionSideEffectTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionSideEffectTests.cs
index b29ae27c..cca637bf 100644
--- a/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionSideEffectTests.cs
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionSideEffectTests.cs
@@ -43,9 +43,11 @@ public class AuthFunctionSideEffectTests
{ CallBase = false };
var delegatedAdminMock = new Mock(
- adminRepo, cache, Mock.Of>())
+ adminRepo,
+ new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Enterprise),
+ cache, Mock.Of>())
{ CallBase = false };
- delegatedAdminMock.Setup(x => x.GetScopeAsync(It.IsAny()))
+ delegatedAdminMock.Setup(x => x.GetScopeAsync(It.IsAny(), It.IsAny()))
.ReturnsAsync(DelegatedScope.Empty);
_tenantAdminsMock = new Mock(
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/DelegatedAdminEditionGateTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/DelegatedAdminEditionGateTests.cs
new file mode 100644
index 00000000..2e3abf40
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/DelegatedAdminEditionGateTests.cs
@@ -0,0 +1,131 @@
+using AutopilotMonitor.Functions.Security;
+using AutopilotMonitor.Functions.Services;
+using AutopilotMonitor.Shared;
+using AutopilotMonitor.Shared.DataAccess;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace AutopilotMonitor.Functions.Tests;
+
+///
+/// Pins the Enterprise entitlement gate in :
+/// the delegated ("MSP") admin's HOME tenant (JWT tid) must be Enterprise for their scope to
+/// take effect — the MANAGED (target) tenants may be any edition (an Enterprise MSP may manage
+/// Community customers). Null/unknown home tenant fails closed to an empty scope. This single
+/// choke-point gates ALL delegated-scope consumers; grant rows stay stored but become inert.
+///
+public class DelegatedAdminEditionGateTests
+{
+ private const string Upn = "msp-admin@partner.example";
+ private const string EnterpriseHomeTenant = "11111111-1111-1111-1111-111111111111";
+ private const string CommunityHomeTenant = "22222222-2222-2222-2222-222222222222";
+ private const string ManagedTenantA = "33333333-3333-3333-3333-333333333333";
+ private const string ManagedTenantB = "44444444-4444-4444-4444-444444444444";
+
+ private static DelegatedAdminEntry Row(string tenantId) => new()
+ {
+ Upn = Upn,
+ TenantId = tenantId,
+ Role = Constants.DelegatedRoles.DelegatedAdmin,
+ IsEnabled = true,
+ Status = Constants.DelegatedStatus.Active,
+ Source = Constants.DelegatedSource.OperatorGranted,
+ };
+
+ /// Entitlements: ONLY the Enterprise home tenant resolves Enterprise — managed targets are Community.
+ private static DelegatedAdminService Build(params DelegatedAdminEntry[] rows)
+ {
+ var repo = new Mock();
+ repo.Setup(r => r.GetDelegatedTenantsAsync(It.IsAny())).ReturnsAsync(rows.ToList());
+ repo.Setup(r => r.GetGroupAssignmentsForUpnAsync(It.IsAny()))
+ .ReturnsAsync(new List());
+ return new DelegatedAdminService(
+ repo.Object,
+ new StubTenantEntitlementService(
+ tenantId => tenantId == EnterpriseHomeTenant ? TenantEdition.Enterprise : TenantEdition.Community),
+ new MemoryCache(new MemoryCacheOptions()),
+ NullLogger.Instance);
+ }
+
+ [Fact]
+ public async Task EnterpriseHomeTenant_KeepsFullScope_EvenOverCommunityTargets()
+ {
+ // Managed targets resolve Community in the stub — they must NOT be dropped.
+ var svc = Build(Row(ManagedTenantA), Row(ManagedTenantB));
+
+ var scope = await svc.GetScopeAsync(Upn, EnterpriseHomeTenant);
+
+ Assert.True(scope.Covers(ManagedTenantA));
+ Assert.True(scope.Covers(ManagedTenantB));
+ }
+
+ [Fact]
+ public async Task CommunityHomeTenant_SuppressesEntireScope()
+ {
+ var svc = Build(Row(ManagedTenantA), Row(ManagedTenantB));
+
+ var scope = await svc.GetScopeAsync(Upn, CommunityHomeTenant);
+
+ Assert.True(scope.IsEmpty);
+ }
+
+ [Fact]
+ public async Task UnknownHomeTenant_FailsClosedToEmptyScope()
+ {
+ var svc = Build(Row(ManagedTenantA));
+
+ Assert.True((await svc.GetScopeAsync(Upn, null)).IsEmpty);
+ Assert.True((await svc.GetScopeAsync(Upn, "")).IsEmpty);
+ }
+
+ [Fact]
+ public async Task GateAppliesAfterCacheRead_NotBakedIntoCachedScope()
+ {
+ // The scope cache is keyed on UPN only — the gate must be re-evaluated per call, so a
+ // Community-homed call must not poison the cache for a later Enterprise-homed call
+ // (and vice versa). Same UPN, different declared home tid (defensive; tid is stable in
+ // practice but the cache must stay pure either way).
+ var svc = Build(Row(ManagedTenantA));
+
+ Assert.True((await svc.GetScopeAsync(Upn, CommunityHomeTenant)).IsEmpty);
+ Assert.True((await svc.GetScopeAsync(Upn, EnterpriseHomeTenant)).Covers(ManagedTenantA));
+ Assert.True((await svc.GetScopeAsync(Upn, CommunityHomeTenant)).IsEmpty);
+ }
+
+ [Fact]
+ public async Task GroupDerivedScope_AlsoGatedByHomeTenant()
+ {
+ var repo = new Mock();
+ repo.Setup(r => r.GetDelegatedTenantsAsync(It.IsAny()))
+ .ReturnsAsync(new List());
+ repo.Setup(r => r.GetGroupAssignmentsForUpnAsync(It.IsAny()))
+ .ReturnsAsync(new List
+ {
+ new()
+ {
+ Upn = Upn,
+ GroupId = "tpl-aaaaaaaa",
+ Role = Constants.DelegatedRoles.DelegatedReader,
+ IsEnabled = true,
+ AssignedBy = "ga@vendor.example",
+ }
+ });
+ repo.Setup(r => r.GetGroupTenantsAsync("tpl-aaaaaaaa"))
+ .ReturnsAsync(new List { ManagedTenantA, ManagedTenantB });
+
+ var svc = new DelegatedAdminService(
+ repo.Object,
+ new StubTenantEntitlementService(
+ tenantId => tenantId == EnterpriseHomeTenant ? TenantEdition.Enterprise : TenantEdition.Community),
+ new MemoryCache(new MemoryCacheOptions()),
+ NullLogger.Instance);
+
+ Assert.True((await svc.GetScopeAsync(Upn, CommunityHomeTenant)).IsEmpty);
+
+ var enterpriseScope = await svc.GetScopeAsync(Upn, EnterpriseHomeTenant);
+ Assert.True(enterpriseScope.Covers(ManagedTenantA));
+ Assert.True(enterpriseScope.Covers(ManagedTenantB));
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/DelegatedAdminServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/DelegatedAdminServiceTests.cs
index 0d319fd8..d757ffc4 100644
--- a/src/Backend/AutopilotMonitor.Functions.Tests/DelegatedAdminServiceTests.cs
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/DelegatedAdminServiceTests.cs
@@ -32,7 +32,13 @@ public class DelegatedAdminServiceTests
repo.Setup(r => r.GetGroupAssignmentsForUpnAsync(It.IsAny()))
.ReturnsAsync(new List());
var cache = new MemoryCache(new MemoryCacheOptions());
- var svc = new DelegatedAdminService(repo.Object, cache, NullLogger.Instance);
+ // Entitlements stubbed to Enterprise for every tenant — these tests pin ROLE resolution;
+ // the edition filter has its own dedicated tests (DelegatedAdminEditionGateTests).
+ var svc = new DelegatedAdminService(
+ repo.Object,
+ new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Enterprise),
+ cache,
+ NullLogger.Instance);
return (svc, repo);
}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/FeatureEntitlementCatalogTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/FeatureEntitlementCatalogTests.cs
new file mode 100644
index 00000000..8c5af094
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/FeatureEntitlementCatalogTests.cs
@@ -0,0 +1,103 @@
+using AutopilotMonitor.Functions.Security;
+using Xunit;
+
+namespace AutopilotMonitor.Functions.Tests;
+
+///
+/// Pins the edition-resolution matrix and the per-edition entitlement values of
+/// . 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.
+///
+public class FeatureEntitlementCatalogTests
+{
+ private static readonly DateTime Now = new(2026, 7, 7, 12, 0, 0, DateTimeKind.Utc);
+
+ // ── ResolveEdition matrix ────────────────────────────────────────────────────
+
+ [Theory]
+ [InlineData("enterprise")]
+ [InlineData("Enterprise")]
+ [InlineData("ENTERPRISE")]
+ [InlineData(" enterprise ")]
+ public void ResolveEdition_EnterpriseTier_IsEnterprise(string tier)
+ {
+ Assert.Equal(TenantEdition.Enterprise, 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)
+ {
+ Assert.Equal(TenantEdition.Community, FeatureEntitlementCatalog.ResolveEdition(tier, null, Now));
+ }
+
+ [Fact]
+ public void ResolveEdition_ActiveTrial_IsEnterprise_EvenOnFreeTier()
+ {
+ Assert.Equal(TenantEdition.Enterprise,
+ FeatureEntitlementCatalog.ResolveEdition("free", Now.AddSeconds(1), Now));
+ }
+
+ [Fact]
+ public void ResolveEdition_TrialExpiringExactlyNow_IsCommunity()
+ {
+ // Strict '>' — a trial ending exactly now is already over.
+ Assert.Equal(TenantEdition.Community,
+ FeatureEntitlementCatalog.ResolveEdition("free", Now, Now));
+ }
+
+ [Fact]
+ public void ResolveEdition_ExpiredTrial_IsCommunity()
+ {
+ Assert.Equal(TenantEdition.Community,
+ FeatureEntitlementCatalog.ResolveEdition(null, Now.AddDays(-1), Now));
+ }
+
+ [Fact]
+ public void ResolveEdition_ExpiredTrial_ButEnterpriseTier_StaysEnterprise()
+ {
+ Assert.Equal(TenantEdition.Enterprise,
+ FeatureEntitlementCatalog.ResolveEdition("enterprise", Now.AddDays(-1), Now));
+ }
+
+ // ── Entitlement values ───────────────────────────────────────────────────────
+
+ [Fact]
+ public void Community_Entitlements_MatchMatrix()
+ {
+ var e = FeatureEntitlementCatalog.Get(TenantEdition.Community);
+ Assert.Equal(90, e.RetentionCapDays);
+ Assert.Null(e.UserRateLimitPerMinute);
+ Assert.Null(e.DeviceRateLimitPerMinute);
+ Assert.False(e.DelegatedAdminAllowed);
+ Assert.Equal("community", e.McpUsagePlanName);
+ Assert.Equal(100, e.McpDailyRequestLimit);
+ Assert.Equal(3000, e.McpMonthlyRequestLimit);
+ }
+
+ [Fact]
+ public void Enterprise_Entitlements_MatchMatrix()
+ {
+ var e = FeatureEntitlementCatalog.Get(TenantEdition.Enterprise);
+ 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.Equal(1000, e.McpDailyRequestLimit);
+ Assert.Equal(20000, e.McpMonthlyRequestLimit);
+ }
+
+ [Fact]
+ public void Get_UnknownEnumValue_FallsBackToCommunity()
+ {
+ var e = FeatureEntitlementCatalog.Get((TenantEdition)42);
+ Assert.Equal(TenantEdition.Community, e.Edition);
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/GetTenantFeatureFlagsPayloadTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/GetTenantFeatureFlagsPayloadTests.cs
index b09a40d0..ff371c77 100644
--- a/src/Backend/AutopilotMonitor.Functions.Tests/GetTenantFeatureFlagsPayloadTests.cs
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/GetTenantFeatureFlagsPayloadTests.cs
@@ -14,9 +14,11 @@ namespace AutopilotMonitor.Functions.Tests;
///
public class GetTenantFeatureFlagsPayloadTests
{
+ private static readonly DateTime Now = new(2026, 7, 7, 12, 0, 0, DateTimeKind.Utc);
+
private static JsonElement Serialize(TenantConfiguration config)
{
- var payload = GetTenantFeatureFlagsFunction.BuildPayload(config);
+ var payload = GetTenantFeatureFlagsFunction.BuildPayload(config, Now);
var json = JsonSerializer.Serialize(payload);
return JsonDocument.Parse(json).RootElement;
}
@@ -33,9 +35,14 @@ public class GetTenantFeatureFlagsPayloadTests
Assert.Equal(new[]
{
"bootstrapTokenEnabled",
+ "edition",
"enableIntegrityBypassAnalyzer",
"enableSoftwareInventoryAnalyzer",
+ "entitlements",
+ "isTrial",
"showScriptOutput",
+ "trialAvailable",
+ "trialExpiresUtc",
"unrestrictedMode",
"validateAutopilotDevice",
}, fieldNames);
@@ -97,7 +104,7 @@ public class GetTenantFeatureFlagsPayloadTests
WebhookUrl = "https://hooks.example.com/services/secret-generic-hook",
};
- var json = JsonSerializer.Serialize(GetTenantFeatureFlagsFunction.BuildPayload(config));
+ var json = JsonSerializer.Serialize(GetTenantFeatureFlagsFunction.BuildPayload(config, Now));
Assert.DoesNotContain("secret-sas-token", json);
Assert.DoesNotContain("secret-team-hook", json);
@@ -106,4 +113,54 @@ public class GetTenantFeatureFlagsPayloadTests
Assert.DoesNotContain("teamsWebhookUrl", json, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("webhookUrl", json, StringComparison.OrdinalIgnoreCase);
}
+
+ // ── Edition / trial surface ─────────────────────────────────────────────
+
+ [Fact]
+ public void Payload_CommunityDefault_EditionAndTrialAvailability()
+ {
+ var element = Serialize(new TenantConfiguration()); // PlanTier default "free" → Community
+
+ Assert.Equal("community", element.GetProperty("edition").GetString());
+ Assert.False(element.GetProperty("isTrial").GetBoolean());
+ Assert.True(element.GetProperty("trialAvailable").GetBoolean());
+ Assert.Equal(90, element.GetProperty("entitlements").GetProperty("retentionCapDays").GetInt32());
+ Assert.False(element.GetProperty("entitlements").GetProperty("delegatedAdminAllowed").GetBoolean());
+ Assert.Equal("community", element.GetProperty("entitlements").GetProperty("mcpUsagePlan").GetString());
+ }
+
+ [Fact]
+ public void Payload_EnterpriseTier_NotTrial()
+ {
+ var element = Serialize(new TenantConfiguration { PlanTier = "enterprise" });
+
+ Assert.Equal("enterprise", 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());
+ Assert.Equal(150, element.GetProperty("entitlements").GetProperty("userRateLimitPerMinute").GetInt32());
+ Assert.True(element.GetProperty("entitlements").GetProperty("delegatedAdminAllowed").GetBoolean());
+ }
+
+ [Fact]
+ public void Payload_ActiveTrial_IsTrialWithExpiry()
+ {
+ var expiry = Now.AddDays(10);
+ var element = Serialize(new TenantConfiguration { TrialExpiresUtc = expiry, TrialConsumed = true });
+
+ Assert.Equal("enterprise", element.GetProperty("edition").GetString());
+ Assert.True(element.GetProperty("isTrial").GetBoolean());
+ Assert.False(element.GetProperty("trialAvailable").GetBoolean());
+ Assert.Equal(expiry, element.GetProperty("trialExpiresUtc").GetDateTime());
+ }
+
+ [Fact]
+ public void Payload_ExpiredTrial_DegradesToCommunity_NoTrialAvailable()
+ {
+ var element = Serialize(new TenantConfiguration { TrialExpiresUtc = Now.AddDays(-1), TrialConsumed = true });
+
+ Assert.Equal("community", element.GetProperty("edition").GetString());
+ Assert.False(element.GetProperty("isTrial").GetBoolean());
+ Assert.False(element.GetProperty("trialAvailable").GetBoolean(), "consumed trial must not be offered again");
+ }
}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/McpQuotaServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/McpQuotaServiceTests.cs
new file mode 100644
index 00000000..9d8c32b5
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/McpQuotaServiceTests.cs
@@ -0,0 +1,228 @@
+using AutopilotMonitor.Functions.Security;
+using AutopilotMonitor.Functions.Services;
+using AutopilotMonitor.Shared.DataAccess;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace AutopilotMonitor.Functions.Tests;
+
+///
+/// Tests for : window math (daily/monthly, reset times), plan
+/// precedence (per-user override → tenant edition; SectionUsagePlans definition → catalog
+/// fallback), and the fail-open contract on counter errors.
+///
+public class McpQuotaServiceTests
+{
+ private const string Oid = "00000000-0000-0000-0000-000000000001";
+ private const string Upn = "alice@contoso.com";
+ private const string TenantId = "11111111-1111-1111-1111-111111111111";
+ private static readonly DateTime Now = new(2026, 7, 7, 15, 30, 0, DateTimeKind.Utc);
+
+ // ── BuildDecision (pure window math) ─────────────────────────────────────────
+
+ [Fact]
+ public void BuildDecision_UnderBothLimits_Allowed()
+ {
+ var d = McpQuotaService.BuildDecision("community", 100, 3000, dailyUsed: 99, monthlyUsed: 500, Now);
+ Assert.True(d.Allowed);
+ Assert.Null(d.Scope);
+ }
+
+ [Fact]
+ public void BuildDecision_DailyExceeded_BlocksWithMidnightReset()
+ {
+ var d = McpQuotaService.BuildDecision("community", 100, 3000, dailyUsed: 100, monthlyUsed: 500, Now);
+ Assert.False(d.Allowed);
+ Assert.Equal("daily", d.Scope);
+ Assert.Equal(new DateTime(2026, 7, 8, 0, 0, 0, DateTimeKind.Utc), d.ResetUtc);
+ }
+
+ [Fact]
+ public void BuildDecision_MonthlyExceeded_BlocksWithFirstOfNextMonthReset()
+ {
+ var d = McpQuotaService.BuildDecision("community", 100, 3000, dailyUsed: 10, monthlyUsed: 3000, Now);
+ Assert.False(d.Allowed);
+ Assert.Equal("monthly", d.Scope);
+ Assert.Equal(new DateTime(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc), d.ResetUtc);
+ }
+
+ [Fact]
+ public void BuildDecision_MonthlyTakesPrecedenceOverDaily()
+ {
+ // Both exceeded → report the longer (monthly) window so Retry-After is honest.
+ var d = McpQuotaService.BuildDecision("community", 100, 3000, dailyUsed: 100, monthlyUsed: 3000, Now);
+ Assert.Equal("monthly", d.Scope);
+ }
+
+ [Fact]
+ public void BuildDecision_ZeroLimit_MeansUnlimitedForThatScope()
+ {
+ var d = McpQuotaService.BuildDecision("custom", 0, 0, dailyUsed: 999999, monthlyUsed: 999999, Now);
+ Assert.True(d.Allowed);
+ }
+
+ // ── CheckAsync (integration over mocked deps) ────────────────────────────────
+
+ private static McpQuotaService Build(
+ Mock usageRepo,
+ string? planDefinitionsJson = null,
+ string? mcpUserPlanOverride = null,
+ TenantEdition edition = TenantEdition.Community)
+ {
+ var adminRepo = new Mock();
+ adminRepo.Setup(r => r.GetMcpUserAsync(It.IsAny()))
+ .ReturnsAsync(mcpUserPlanOverride == null
+ ? null
+ : new McpUserEntry { Upn = Upn, IsEnabled = true, UsagePlan = mcpUserPlanOverride });
+
+ var cache = new MemoryCache(new MemoryCacheOptions());
+ var mcpUserService = new McpUserService(
+ adminRepo.Object, cache, NullLogger.Instance,
+ globalAdminService: null!, delegatedAdminService: null!, adminConfigService: null!);
+
+ var configRepo = new Mock();
+ configRepo.Setup(r => r.GetAdminConfigurationAsync())
+ .ReturnsAsync(new AutopilotMonitor.Shared.Models.AdminConfiguration
+ {
+ UpdatedBy = "test",
+ PlanTierDefinitionsJson = planDefinitionsJson
+ });
+ var adminConfigService = new AdminConfigurationService(
+ configRepo.Object, NullLogger.Instance, cache);
+
+ return new McpQuotaService(
+ usageRepo.Object,
+ mcpUserService,
+ adminConfigService,
+ new StubTenantEntitlementService(edition),
+ cache,
+ NullLogger.Instance,
+ new TestTimeProvider(Now));
+ }
+
+ private static Mock UsageRepo(params (string Date, long Count)[] rows)
+ {
+ var repo = new Mock();
+ repo.Setup(r => r.GetUsageByUserAsync(Oid, It.IsAny(), It.IsAny()))
+ .ReturnsAsync(rows.Select(r => new UserUsageRecord
+ {
+ UserId = Oid,
+ Date = r.Date,
+ RequestCount = r.Count
+ }).ToList());
+ return repo;
+ }
+
+ [Fact]
+ public async Task Check_CommunityFallback_DailyLimitEnforced()
+ {
+ // 100 requests today (catalog Community fallback: 100/day) → blocked.
+ var svc = Build(UsageRepo(("20260707", 100)));
+
+ var d = await svc.CheckAsync(Oid, Upn, TenantId);
+
+ Assert.False(d.Allowed);
+ Assert.Equal("community", d.Plan);
+ Assert.Equal("daily", d.Scope);
+ Assert.Equal(100, d.DailyLimit);
+ }
+
+ [Fact]
+ public async Task Check_EnterpriseEdition_UsesEnterpriseFallbackLimits()
+ {
+ var svc = Build(UsageRepo(("20260707", 100)), edition: TenantEdition.Enterprise);
+
+ var d = await svc.CheckAsync(Oid, Upn, TenantId);
+
+ Assert.True(d.Allowed);
+ Assert.Equal("enterprise", d.Plan);
+ Assert.Equal(1000, d.DailyLimit);
+ Assert.Equal(20000, d.MonthlyLimit);
+ }
+
+ [Fact]
+ public async Task Check_MonthlySum_SpansAllRowsOfTheMonth()
+ {
+ // 2990 across the month + 20 today = 3010 ≥ 3000 monthly Community limit.
+ var svc = Build(UsageRepo(("20260701", 1500), ("20260703", 1490), ("20260707", 20)));
+
+ var d = await svc.CheckAsync(Oid, Upn, TenantId);
+
+ Assert.False(d.Allowed);
+ Assert.Equal("monthly", d.Scope);
+ Assert.Equal(3010, d.MonthlyUsed);
+ Assert.Equal(20, d.DailyUsed);
+ }
+
+ [Fact]
+ public async Task Check_AdminDefinedPlan_OverridesCatalogFallback()
+ {
+ var json = """[{"name":"community","dailyRequestLimit":5,"monthlyRequestLimit":50,"description":""}]""";
+ var svc = Build(UsageRepo(("20260707", 5)), planDefinitionsJson: json);
+
+ var d = await svc.CheckAsync(Oid, Upn, TenantId);
+
+ Assert.False(d.Allowed);
+ Assert.Equal(5, d.DailyLimit);
+ Assert.Equal(50, d.MonthlyLimit);
+ }
+
+ [Fact]
+ public async Task Check_PerUserOverride_WinsOverTenantEdition()
+ {
+ var json = """[{"name":"power","dailyRequestLimit":10000,"monthlyRequestLimit":100000,"description":""}]""";
+ var svc = Build(UsageRepo(("20260707", 500)), planDefinitionsJson: json, mcpUserPlanOverride: "power");
+
+ var d = await svc.CheckAsync(Oid, Upn, TenantId);
+
+ Assert.True(d.Allowed);
+ Assert.Equal("power", d.Plan);
+ Assert.Equal(10000, d.DailyLimit);
+ }
+
+ [Fact]
+ public async Task Check_UnknownOverridePlan_FailsClosedToCommunityLimits()
+ {
+ // Override names a plan that exists nowhere → Community fallback limits.
+ var svc = Build(UsageRepo(("20260707", 100)), mcpUserPlanOverride: "no-such-plan");
+
+ var d = await svc.CheckAsync(Oid, Upn, TenantId);
+
+ Assert.False(d.Allowed);
+ Assert.Equal("no-such-plan", d.Plan);
+ Assert.Equal(100, d.DailyLimit);
+ }
+
+ [Fact]
+ public async Task Check_UsageLookupThrows_FailsOpen_AndDoesNotCache()
+ {
+ var repo = new Mock();
+ repo.Setup(r => r.GetUsageByUserAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .ThrowsAsync(new InvalidOperationException("storage down"));
+ var svc = Build(repo);
+
+ var first = await svc.CheckAsync(Oid, Upn, TenantId);
+ var second = await svc.CheckAsync(Oid, Upn, TenantId);
+
+ Assert.True(first.Allowed);
+ Assert.True(second.Allowed);
+ // Fail-open decisions are not cached — the counter read is retried every request.
+ repo.Verify(r => r.GetUsageByUserAsync(It.IsAny(), It.IsAny(), It.IsAny()),
+ Times.Exactly(2));
+ }
+
+ [Fact]
+ public async Task Check_Decision_IsCachedPerUser()
+ {
+ var repo = UsageRepo(("20260707", 1));
+ var svc = Build(repo);
+
+ await svc.CheckAsync(Oid, Upn, TenantId);
+ await svc.CheckAsync(Oid, Upn, TenantId);
+
+ repo.Verify(r => r.GetUsageByUserAsync(It.IsAny(), It.IsAny(), It.IsAny()),
+ Times.Once);
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/McpUserServiceDelegatedTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/McpUserServiceDelegatedTests.cs
index 39869716..241a2992 100644
--- a/src/Backend/AutopilotMonitor.Functions.Tests/McpUserServiceDelegatedTests.cs
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/McpUserServiceDelegatedTests.cs
@@ -35,13 +35,15 @@ public class McpUserServiceDelegatedTests
_globalAdmin = new Mock(
_adminRepo.Object, cache, NullLogger.Instance) { CallBase = false };
_delegatedAdmin = new Mock(
- _adminRepo.Object, cache, NullLogger.Instance) { CallBase = false };
+ _adminRepo.Object,
+ new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Enterprise),
+ cache, NullLogger.Instance) { CallBase = false };
_adminConfig = new Mock(
Mock.Of(), NullLogger.Instance, cache) { CallBase = false };
// Defaults: no platform role, no delegated scope, WhitelistOnly, no McpUsers row (NotPresent).
_globalAdmin.Setup(x => x.GetGlobalRoleAsync(It.IsAny())).ReturnsAsync((string?)null);
- _delegatedAdmin.Setup(x => x.GetScopeAsync(It.IsAny())).ReturnsAsync(DelegatedScope.Empty);
+ _delegatedAdmin.Setup(x => x.GetScopeAsync(It.IsAny(), It.IsAny())).ReturnsAsync(DelegatedScope.Empty);
_adminRepo.Setup(x => x.GetMcpUserAsync(It.IsAny())).ReturnsAsync((McpUserEntry?)null);
SetPolicy(McpAccessPolicy.WhitelistOnly);
@@ -59,7 +61,7 @@ public class McpUserServiceDelegatedTests
var map = new Dictionary(StringComparer.OrdinalIgnoreCase);
foreach (var (tenant, role) in assignments)
map[tenant.ToLowerInvariant()] = role;
- _delegatedAdmin.Setup(x => x.GetScopeAsync(It.IsAny()))
+ _delegatedAdmin.Setup(x => x.GetScopeAsync(It.IsAny(), It.IsAny()))
.ReturnsAsync(new DelegatedScope(map));
}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs
index a36ca985..6b4bc426 100644
--- a/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs
@@ -84,7 +84,13 @@ public class PolicyEnforcementMiddlewareTests
var cache = new MemoryCache(new MemoryCacheOptions());
var globalAdmin = new GlobalAdminService(repo.Object, cache, NullLogger.Instance);
- var delegatedAdmin = new DelegatedAdminService(repo.Object, cache, NullLogger.Instance);
+ // Entitlements stubbed to Enterprise — the delegated-rescue tests here pin AUTHORIZATION
+ // mechanics; the Enterprise-only edition gate is covered by DelegatedAdminEditionGateTests.
+ var delegatedAdmin = new DelegatedAdminService(
+ repo.Object,
+ new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Enterprise),
+ cache,
+ NullLogger.Instance);
var tenantAdmins = new TenantAdminsService(repo.Object, cache, NullLogger.Instance);
var tenantConfig = new TenantConfigurationService(
configRepo.Object, NullLogger.Instance, cache);
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/RateLimitResolverTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/RateLimitResolverTests.cs
index 5a2ffae6..cffdc073 100644
--- a/src/Backend/AutopilotMonitor.Functions.Tests/RateLimitResolverTests.cs
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/RateLimitResolverTests.cs
@@ -46,4 +46,63 @@ public class RateLimitResolverTests
isGlobalAdmin: true, tenantUserOverride: 300, globalUserDefault: 120, globalAdminDefault: 600);
Assert.Equal(600, limit);
}
+
+ // ── Edition entitlement floor (Enterprise: raises the DEFAULT, never beats an override) ──
+
+ [Fact]
+ public void ResolveUserLimit_EnterpriseFloor_RaisesDefault()
+ {
+ var limit = RateLimitResolver.ResolveUserLimit(
+ isGlobalAdmin: false, tenantUserOverride: null, globalUserDefault: 120, globalAdminDefault: 600,
+ entitlementFloor: 150);
+ Assert.Equal(150, limit);
+ }
+
+ [Fact]
+ public void ResolveUserLimit_FloorBelowAdminRaisedDefault_DefaultWins()
+ {
+ // An admin-raised global default (200) must never be LOWERED by the entitlement floor.
+ var limit = RateLimitResolver.ResolveUserLimit(
+ isGlobalAdmin: false, tenantUserOverride: null, globalUserDefault: 200, globalAdminDefault: 600,
+ entitlementFloor: 150);
+ Assert.Equal(200, limit);
+ }
+
+ [Fact]
+ public void ResolveUserLimit_ExplicitOverride_BeatsEnterpriseFloor()
+ {
+ // A GA-set per-tenant override (e.g. deliberate throttle to 50) wins outright — the floor
+ // only raises the default, it must not undo a targeted override.
+ var limit = RateLimitResolver.ResolveUserLimit(
+ isGlobalAdmin: false, tenantUserOverride: 50, globalUserDefault: 120, globalAdminDefault: 600,
+ entitlementFloor: 150);
+ Assert.Equal(50, limit);
+ }
+
+ [Fact]
+ public void ResolveUserLimit_GlobalAdmin_FloorNeverApplies()
+ {
+ var limit = RateLimitResolver.ResolveUserLimit(
+ isGlobalAdmin: true, tenantUserOverride: null, globalUserDefault: 120, globalAdminDefault: 600,
+ entitlementFloor: 9999);
+ Assert.Equal(600, limit);
+ }
+
+ [Fact]
+ public void ResolveDeviceLimit_EnterpriseFloor_RaisesDefault()
+ {
+ Assert.Equal(150, RateLimitResolver.ResolveDeviceLimit(tenantOverride: null, globalDefault: 100, entitlementFloor: 150));
+ }
+
+ [Fact]
+ public void ResolveDeviceLimit_ExplicitOverride_BeatsEnterpriseFloor()
+ {
+ Assert.Equal(50, RateLimitResolver.ResolveDeviceLimit(tenantOverride: 50, globalDefault: 100, entitlementFloor: 150));
+ }
+
+ [Fact]
+ public void ResolveDeviceLimit_NullFloor_CommunityUnchanged()
+ {
+ Assert.Equal(100, RateLimitResolver.ResolveDeviceLimit(tenantOverride: null, globalDefault: 100, entitlementFloor: null));
+ }
}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/SessionRetentionFanoutServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/SessionRetentionFanoutServiceTests.cs
index b8d6a202..7015832b 100644
--- a/src/Backend/AutopilotMonitor.Functions.Tests/SessionRetentionFanoutServiceTests.cs
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/SessionRetentionFanoutServiceTests.cs
@@ -46,8 +46,9 @@ public class SessionRetentionFanoutServiceTests
var harness = new Harness();
// Tenant A: 30d retention → eligible at 45d
harness.WithTenant(TenantA, retentionDays: 30, sessions: new[] { Old("a1", 45) });
- // Tenant B: 120d retention → 45d-old session is NOT eligible (only 200d-old is)
- harness.WithTenant(TenantB, retentionDays: 120, sessions: new[] { Old("b1", 45), Old("b2", 200) });
+ // Tenant B: 120d retention — above the Community cap (90), so B must be Enterprise for
+ // the 120d cutoff to remain effective (Community would be clamped to 90).
+ harness.WithTenant(TenantB, retentionDays: 120, sessions: new[] { Old("b1", 45), Old("b2", 200) }, planTier: "enterprise");
// The repo only returns sessions older than each cutoff. Wire each tenant's mock to honor that.
harness.MaintenanceRepo.Setup(m => m.GetSessionsOlderThanAsync(TenantA, It.IsAny(), It.IsAny(), It.IsAny()))
@@ -119,6 +120,37 @@ public class SessionRetentionFanoutServiceTests
harness.MaintenanceRepo.Verify(m => m.GetSessionsOlderThanAsync(TenantA, It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
}
+ [Fact]
+ public async Task RunAsync_clamps_community_retention_to_90_days()
+ {
+ // Edition retention cap (fail-closed backstop): a Community tenant with a stored value
+ // above the cap (e.g. legacy 180d, or a trial that expired) is enforced at 90 — the
+ // cutoff passed to storage must be ~90 days, not the stored 180.
+ var harness = new Harness();
+ harness.WithTenant(TenantA, retentionDays: 180, sessions: new[] { Old("s1", 100) });
+
+ await harness.Sut.RunAsync(CancellationToken.None);
+
+ harness.MaintenanceRepo.Verify(m => m.GetSessionsOlderThanAsync(
+ TenantA,
+ It.Is(d => d > DateTime.UtcNow.AddDays(-91) && d <= DateTime.UtcNow.AddDays(-89)),
+ It.IsAny(), It.IsAny()), Times.Once);
+ }
+
+ [Fact]
+ public async Task RunAsync_does_not_clamp_enterprise_retention_within_365()
+ {
+ var harness = new Harness();
+ harness.WithTenant(TenantA, retentionDays: 180, sessions: new[] { Old("s1", 200) }, planTier: "enterprise");
+
+ await harness.Sut.RunAsync(CancellationToken.None);
+
+ harness.MaintenanceRepo.Verify(m => m.GetSessionsOlderThanAsync(
+ TenantA,
+ It.Is(d => d > DateTime.UtcNow.AddDays(-181) && d <= DateTime.UtcNow.AddDays(-179)),
+ It.IsAny(), It.IsAny()), Times.Once);
+ }
+
// ────────────────────────────────────────────────────────────────────────── PR6 follow-up F2 ─
[Fact]
@@ -339,11 +371,11 @@ public class SessionRetentionFanoutServiceTests
throttle: (_, _) => Task.CompletedTask);
}
- public void WithTenant(string tenantId, int retentionDays, SessionSummary[] sessions)
+ public void WithTenant(string tenantId, int retentionDays, SessionSummary[] sessions, string planTier = "free")
{
_tenantIds.Add(tenantId);
TenantConfig.Setup(t => t.GetConfigurationAsync(tenantId))
- .ReturnsAsync(new TenantConfiguration { TenantId = tenantId, DataRetentionDays = retentionDays });
+ .ReturnsAsync(new TenantConfiguration { TenantId = tenantId, DataRetentionDays = retentionDays, PlanTier = planTier });
MaintenanceRepo.Setup(m => m.GetSessionsOlderThanAsync(tenantId, It.IsAny(), It.IsAny(), It.IsAny()))
.ReturnsAsync(new List(WithTenantId(tenantId, sessions)));
}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/StubTenantEntitlementService.cs b/src/Backend/AutopilotMonitor.Functions.Tests/StubTenantEntitlementService.cs
new file mode 100644
index 00000000..bc4d37f5
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/StubTenantEntitlementService.cs
@@ -0,0 +1,31 @@
+using AutopilotMonitor.Functions.Security;
+using AutopilotMonitor.Functions.Services;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace AutopilotMonitor.Functions.Tests;
+
+///
+/// Test stub for — returns a fixed (or per-tenant) edition
+/// without any storage dependency. Used wherever a service under test consumes entitlements but
+/// the test's subject is something else (e.g. delegated-scope role resolution).
+///
+internal sealed class StubTenantEntitlementService : TenantEntitlementService
+{
+ private readonly Func _resolver;
+
+ public StubTenantEntitlementService(TenantEdition edition) : this(_ => edition)
+ {
+ }
+
+ public StubTenantEntitlementService(Func resolver)
+ : base(configService: null!, logger: NullLogger.Instance)
+ {
+ _resolver = resolver;
+ }
+
+ public override Task GetEditionAsync(string? tenantId)
+ => Task.FromResult(_resolver(tenantId));
+
+ public override Task GetEntitlementsAsync(string? tenantId)
+ => Task.FromResult(FeatureEntitlementCatalog.Get(_resolver(tenantId)));
+}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigProjectionTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigProjectionTests.cs
index bbe675eb..274480c3 100644
--- a/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigProjectionTests.cs
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigProjectionTests.cs
@@ -27,7 +27,8 @@ public class TenantConfigProjectionTests
private static readonly string[] AllSafeKeys =
{
- "tenantId", "domainName", "planTier", "disabled", "disabledReason",
+ "tenantId", "domainName", "planTier", "trialExpiresUtc", "trialConsumed",
+ "disabled", "disabledReason",
"onboardedAt", "onboardedBy", "lastUpdated", "dataRetentionDays",
};
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigTableSerializationTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigTableSerializationTests.cs
new file mode 100644
index 00000000..e6972834
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigTableSerializationTests.cs
@@ -0,0 +1,88 @@
+using AutopilotMonitor.Functions.DataAccess.TableStorage;
+using AutopilotMonitor.Shared.Models;
+using Azure.Data.Tables;
+using Xunit;
+
+namespace AutopilotMonitor.Functions.Tests;
+
+///
+/// Store↔Map roundtrip for the tenant-config table serialization, focused on the plan/trial
+/// fields (project rule "table-serialization": every model field must appear in BOTH
+/// ConvertToTenantTableEntity and ConvertFromTenantTableEntity). Includes legacy-row shapes
+/// (rows written before the trial columns existed) which must map to safe defaults.
+///
+public class TenantConfigTableSerializationTests
+{
+ private const string TenantId = "11111111-1111-1111-1111-111111111111";
+
+ [Fact]
+ public void Roundtrip_PlanAndTrialFields_SurviveStoreAndMap()
+ {
+ var expires = new DateTime(2026, 8, 6, 10, 30, 0, DateTimeKind.Utc);
+ var started = new DateTime(2026, 7, 7, 10, 30, 0, DateTimeKind.Utc);
+ var config = new TenantConfiguration
+ {
+ TenantId = TenantId,
+ DomainName = "contoso.com",
+ UpdatedBy = "admin@contoso.com",
+ PlanTier = "enterprise",
+ TrialExpiresUtc = expires,
+ TrialStartedUtc = started,
+ TrialConsumed = true,
+ TrialGrantedBy = "alice@contoso.com"
+ };
+
+ var entity = TableConfigRepository.ConvertToTenantTableEntity(config);
+ var mapped = TableConfigRepository.ConvertFromTenantTableEntity(entity);
+
+ Assert.Equal("enterprise", mapped.PlanTier);
+ Assert.Equal(expires, mapped.TrialExpiresUtc);
+ Assert.Equal(started, mapped.TrialStartedUtc);
+ Assert.True(mapped.TrialConsumed);
+ Assert.Equal("alice@contoso.com", mapped.TrialGrantedBy);
+ }
+
+ [Fact]
+ public void Roundtrip_NoTrial_NullsStayNull()
+ {
+ var config = new TenantConfiguration
+ {
+ TenantId = TenantId,
+ DomainName = "contoso.com",
+ UpdatedBy = "admin@contoso.com"
+ };
+
+ var mapped = TableConfigRepository.ConvertFromTenantTableEntity(
+ TableConfigRepository.ConvertToTenantTableEntity(config));
+
+ Assert.Null(mapped.TrialExpiresUtc);
+ Assert.Null(mapped.TrialStartedUtc);
+ Assert.False(mapped.TrialConsumed);
+ Assert.Null(mapped.TrialGrantedBy);
+ }
+
+ [Fact]
+ public void Map_LegacyRow_WithoutTrialColumns_DefaultsSafely()
+ {
+ // A row written before the trial fields existed: no Trial* columns at all, legacy tier.
+ var entity = new TableEntity(TenantId, "config")
+ {
+ { "DomainName", "fabrikam.com" },
+ { "PlanTier", "pro" }
+ };
+
+ var mapped = TableConfigRepository.ConvertFromTenantTableEntity(entity);
+
+ Assert.Equal("pro", mapped.PlanTier); // legacy value preserved (resolves to Community at read time)
+ Assert.Null(mapped.TrialExpiresUtc);
+ Assert.False(mapped.TrialConsumed);
+ Assert.Null(mapped.TrialGrantedBy);
+ }
+
+ [Fact]
+ public void Map_LegacyRow_WithoutPlanTier_DefaultsToFree()
+ {
+ var entity = new TableEntity(TenantId, "config") { { "DomainName", "fabrikam.com" } };
+ Assert.Equal("free", TableConfigRepository.ConvertFromTenantTableEntity(entity).PlanTier);
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TenantEntitlementServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TenantEntitlementServiceTests.cs
new file mode 100644
index 00000000..7293ae65
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/TenantEntitlementServiceTests.cs
@@ -0,0 +1,144 @@
+using AutopilotMonitor.Functions.Security;
+using AutopilotMonitor.Functions.Services;
+using AutopilotMonitor.Shared.DataAccess;
+using AutopilotMonitor.Shared.Models;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace AutopilotMonitor.Functions.Tests;
+
+///
+/// Tests for : read-time edition resolution on top of the
+/// tenant-config cache, the fail-closed contract (no row / storage error → Community), and the
+/// retention clamp ( ).
+///
+public class TenantEntitlementServiceTests
+{
+ private const string TenantId = "11111111-1111-1111-1111-111111111111";
+ private static readonly DateTime Now = new(2026, 7, 7, 12, 0, 0, DateTimeKind.Utc);
+
+ private static (TenantEntitlementService Svc, Mock Repo) Build(TenantConfiguration? config)
+ {
+ var repo = new Mock();
+ repo.Setup(r => r.GetTenantConfigurationAsync(It.IsAny())).ReturnsAsync(config);
+ var configService = new TenantConfigurationService(
+ repo.Object, NullLogger.Instance, new MemoryCache(new MemoryCacheOptions()));
+ var svc = new TenantEntitlementService(
+ configService, NullLogger.Instance, new TestTimeProvider(Now));
+ return (svc, repo);
+ }
+
+ [Fact]
+ public async Task GetEdition_EnterpriseTier_ReturnsEnterprise()
+ {
+ var (svc, _) = Build(new TenantConfiguration { TenantId = TenantId, PlanTier = "enterprise" });
+ Assert.Equal(TenantEdition.Enterprise, await svc.GetEditionAsync(TenantId));
+ }
+
+ [Fact]
+ public async Task GetEdition_ActiveTrial_ReturnsEnterprise()
+ {
+ var (svc, _) = Build(new TenantConfiguration
+ {
+ TenantId = TenantId,
+ PlanTier = "free",
+ TrialExpiresUtc = Now.AddDays(3)
+ });
+ Assert.Equal(TenantEdition.Enterprise, await svc.GetEditionAsync(TenantId));
+ }
+
+ [Fact]
+ public async Task GetEdition_NoRow_FailsClosedToCommunity()
+ {
+ var (svc, _) = Build(config: null);
+ Assert.Equal(TenantEdition.Community, await svc.GetEditionAsync(TenantId));
+ }
+
+ [Fact]
+ public async Task GetEdition_StorageError_FailsClosedToCommunity()
+ {
+ var repo = new Mock();
+ repo.Setup(r => r.GetTenantConfigurationAsync(It.IsAny()))
+ .ThrowsAsync(new InvalidOperationException("storage down"));
+ var configService = new TenantConfigurationService(
+ repo.Object, NullLogger.Instance, new MemoryCache(new MemoryCacheOptions()));
+ var svc = new TenantEntitlementService(
+ configService, NullLogger.Instance, new TestTimeProvider(Now));
+
+ Assert.Equal(TenantEdition.Community, await svc.GetEditionAsync(TenantId));
+ }
+
+ [Fact]
+ public async Task GetEdition_EmptyTenantId_ReturnsCommunity()
+ {
+ var (svc, repo) = Build(new TenantConfiguration { TenantId = TenantId, PlanTier = "enterprise" });
+ Assert.Equal(TenantEdition.Community, await svc.GetEditionAsync(""));
+ Assert.Equal(TenantEdition.Community, await svc.GetEditionAsync(null));
+ repo.Verify(r => r.GetTenantConfigurationAsync(It.IsAny()), Times.Never);
+ }
+
+ [Fact]
+ public async Task GetEdition_NeverMaterializesAConfigRow()
+ {
+ // Uses the strict point-read: an entitlement check for an unregistered tenant must not
+ // auto-create + persist a default config row.
+ var (svc, repo) = Build(config: null);
+ await svc.GetEditionAsync(TenantId);
+ repo.Verify(r => r.SaveTenantConfigurationAsync(It.IsAny()), Times.Never);
+ }
+
+ [Fact]
+ public async Task GetEntitlements_ExpiredTrial_ReturnsCommunityValues()
+ {
+ var (svc, _) = Build(new TenantConfiguration
+ {
+ TenantId = TenantId,
+ PlanTier = "free",
+ TrialExpiresUtc = Now.AddDays(-1),
+ TrialConsumed = true
+ });
+ var e = await svc.GetEntitlementsAsync(TenantId);
+ Assert.Equal(TenantEdition.Community, e.Edition);
+ Assert.Equal(90, e.RetentionCapDays);
+ }
+
+ // ── GetEffectiveRetentionDays ────────────────────────────────────────────────
+
+ [Theory]
+ [InlineData("free", 90, 90)] // at the cap — unchanged
+ [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
+ 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));
+ }
+
+ [Fact]
+ public void GetEffectiveRetentionDays_ZeroInfinite_IsNeverClamped()
+ {
+ // 0 = GA-only "infinite" escape hatch — passes through regardless of edition.
+ var config = new TenantConfiguration { TenantId = TenantId, PlanTier = "free", DataRetentionDays = 0 };
+ Assert.Equal(0, TenantEntitlementService.GetEffectiveRetentionDays(config, Now));
+ }
+
+ [Fact]
+ public void GetEffectiveRetentionDays_TrialExpiry_DegradesCapFrom365To90()
+ {
+ var config = new TenantConfiguration
+ {
+ TenantId = TenantId,
+ PlanTier = "free",
+ DataRetentionDays = 365,
+ TrialExpiresUtc = Now.AddDays(1)
+ };
+ Assert.Equal(365, TenantEntitlementService.GetEffectiveRetentionDays(config, Now));
+ Assert.Equal(90, TenantEntitlementService.GetEffectiveRetentionDays(config, Now.AddDays(2)));
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TestTimeProvider.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TestTimeProvider.cs
new file mode 100644
index 00000000..ab14fb8f
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/TestTimeProvider.cs
@@ -0,0 +1,16 @@
+namespace AutopilotMonitor.Functions.Tests;
+
+///
+/// Shared fixed-clock for tests that need deterministic time math
+/// (trial expiry, quota windows). Mutable via to simulate passage of time.
+///
+internal sealed class TestTimeProvider : TimeProvider
+{
+ private DateTimeOffset _now;
+
+ public TestTimeProvider(DateTimeOffset now) => _now = now;
+
+ public void SetUtcNow(DateTimeOffset now) => _now = now;
+
+ public override DateTimeOffset GetUtcNow() => _now;
+}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TrialExpirySweepFunctionTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TrialExpirySweepFunctionTests.cs
new file mode 100644
index 00000000..7ac0c1d3
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/TrialExpirySweepFunctionTests.cs
@@ -0,0 +1,182 @@
+using AutopilotMonitor.Functions.Functions.Maintenance;
+using AutopilotMonitor.Functions.Services;
+using AutopilotMonitor.Functions.Services.Notifications;
+using AutopilotMonitor.Shared.DataAccess;
+using AutopilotMonitor.Shared.Models;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace AutopilotMonitor.Functions.Tests;
+
+///
+/// Tests for — the informational daily sweep that surfaces
+/// trial transitions as ops events (TenantTrialExpired within the 24h look-back, TenantTrialExpiring
+/// within the 3-day heads-up). Enforcement is read-time; this timer is visibility only.
+///
+public class TrialExpirySweepFunctionTests
+{
+ private static readonly DateTime Now = new(2026, 7, 7, 3, 30, 0, DateTimeKind.Utc);
+
+ private static TenantConfiguration Tenant(
+ string id, DateTime? trialExpiresUtc, string planTier = "free", string domain = "contoso.com") => new()
+ {
+ TenantId = id,
+ DomainName = domain,
+ UpdatedBy = "test",
+ PlanTier = planTier,
+ TrialExpiresUtc = trialExpiresUtc,
+ TrialConsumed = trialExpiresUtc.HasValue,
+ };
+
+ private static (TrialExpirySweepFunction Sut, List Events) Build(params TenantConfiguration[] configs)
+ {
+ var configRepo = new Mock();
+ configRepo.Setup(r => r.GetAllTenantConfigurationsAsync()).ReturnsAsync(configs.ToList());
+
+ var events = new List();
+ var opsRepo = new Mock();
+ opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny()))
+ .Callback(events.Add)
+ .Returns(Task.CompletedTask);
+
+ var cache = new MemoryCache(new MemoryCacheOptions());
+ var adminConfig = new Mock(
+ Mock.Of(), NullLogger.Instance, cache)
+ { CallBase = false };
+ adminConfig.Setup(a => a.GetConfigurationAsync()).ReturnsAsync(new AdminConfiguration { UpdatedBy = "test" });
+
+ var alertDispatch = new OpsAlertDispatchService(
+ adminConfig.Object,
+ new TelegramNotificationService(new HttpClient(), Mock.Of(), NullLogger.Instance),
+ new WebhookNotificationService(new HttpClient(), NullLogger.Instance),
+ NullLogger.Instance);
+ var opsService = new OpsEventService(opsRepo.Object, NullLogger.Instance, alertDispatch);
+
+ var sut = new TrialExpirySweepFunction(
+ configRepo.Object, opsService, NullLogger.Instance,
+ new TestTimeProvider(Now));
+ return (sut, events);
+ }
+
+ [Fact]
+ public async Task ExpiredWithinLookBack_EmitsTenantTrialExpired()
+ {
+ var (sut, events) = Build(Tenant("t1", Now.AddHours(-2)));
+
+ var result = await sut.RunCoreAsync(CancellationToken.None);
+
+ Assert.Equal(1, result.ExpiredEmitted);
+ var e = Assert.Single(events);
+ Assert.Equal("TenantTrialExpired", e.EventType);
+ Assert.Equal("t1", e.TenantId);
+ Assert.Equal(OpsEventSeverity.Warning, e.Severity);
+ }
+
+ [Fact]
+ public async Task ExpiredBeforeLookBack_StaysSilent()
+ {
+ // Reported by a previous daily run — must not re-emit forever.
+ var (sut, events) = Build(Tenant("t1", Now.AddHours(-30)));
+
+ var result = await sut.RunCoreAsync(CancellationToken.None);
+
+ Assert.Equal(0, result.ExpiredEmitted);
+ Assert.Empty(events);
+ }
+
+ [Fact]
+ public async Task ExpiringWithinHeadsUp_EmitsTenantTrialExpiring_WithDaysLeft()
+ {
+ var (sut, events) = Build(Tenant("t1", Now.AddDays(2)));
+
+ var result = await sut.RunCoreAsync(CancellationToken.None);
+
+ Assert.Equal(1, result.ExpiringEmitted);
+ var e = Assert.Single(events);
+ Assert.Equal("TenantTrialExpiring", e.EventType);
+ Assert.Equal(OpsEventSeverity.Info, e.Severity);
+ Assert.Contains("2 day", e.Message);
+ }
+
+ [Fact]
+ public async Task ExpiringBeyondHeadsUp_StaysSilent()
+ {
+ var (sut, events) = Build(Tenant("t1", Now.AddDays(5)));
+
+ await sut.RunCoreAsync(CancellationToken.None);
+
+ Assert.Empty(events);
+ }
+
+ [Fact]
+ public async Task PermanentEnterpriseTenant_IsSkipped_TrialTimestampsAreInert()
+ {
+ // Upgraded mid-trial: PlanTier=enterprise — expiry changes nothing, no noise.
+ var (sut, events) = Build(Tenant("t1", Now.AddHours(-1), planTier: "enterprise"));
+
+ var result = await sut.RunCoreAsync(CancellationToken.None);
+
+ Assert.Equal(0, result.TrialsSeen);
+ Assert.Empty(events);
+ }
+
+ [Fact]
+ public async Task TenantsWithoutTrial_AreIgnored()
+ {
+ var (sut, events) = Build(Tenant("t1", null), Tenant("t2", null, planTier: "enterprise"));
+
+ var result = await sut.RunCoreAsync(CancellationToken.None);
+
+ Assert.Equal(0, result.TrialsSeen);
+ Assert.Empty(events);
+ }
+
+ [Fact]
+ public async Task MixedFleet_EmitsPerTenant()
+ {
+ var (sut, events) = Build(
+ Tenant("expired", Now.AddHours(-3)),
+ Tenant("expiring", Now.AddDays(1)),
+ Tenant("healthy", Now.AddDays(20)),
+ Tenant("no-trial", null));
+
+ var result = await sut.RunCoreAsync(CancellationToken.None);
+
+ Assert.Equal(3, result.TrialsSeen);
+ Assert.Equal(1, result.ExpiredEmitted);
+ Assert.Equal(1, result.ExpiringEmitted);
+ Assert.Equal(2, events.Count);
+ }
+
+ [Fact]
+ public async Task ConfigLoadFailure_ReturnsEmptyResult_NeverThrows()
+ {
+ var configRepo = new Mock();
+ configRepo.Setup(r => r.GetAllTenantConfigurationsAsync())
+ .ThrowsAsync(new InvalidOperationException("storage down"));
+
+ var opsRepo = new Mock();
+ var cache = new MemoryCache(new MemoryCacheOptions());
+ var adminConfig = new Mock(
+ Mock.Of(), NullLogger.Instance, cache)
+ { CallBase = false };
+ adminConfig.Setup(a => a.GetConfigurationAsync()).ReturnsAsync(new AdminConfiguration { UpdatedBy = "test" });
+ var alertDispatch = new OpsAlertDispatchService(
+ adminConfig.Object,
+ new TelegramNotificationService(new HttpClient(), Mock.Of(), NullLogger.Instance),
+ new WebhookNotificationService(new HttpClient(), NullLogger.Instance),
+ NullLogger.Instance);
+
+ var sut = new TrialExpirySweepFunction(
+ configRepo.Object,
+ new OpsEventService(opsRepo.Object, NullLogger.Instance, alertDispatch),
+ NullLogger.Instance,
+ new TestTimeProvider(Now));
+
+ var result = await sut.RunCoreAsync(CancellationToken.None);
+
+ Assert.Equal(0, result.TrialsSeen);
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs
index 69562faa..12a4eb31 100644
--- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs
+++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs
@@ -324,7 +324,11 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
// --- Tenant Configuration Entity Mapping ---
- private TableEntity ConvertToTenantTableEntity(TenantConfiguration config)
+ // Internal static (not private): the Store↔Map pair is a serialization CONTRACT — every
+ // model field must appear in BOTH methods (project rule "table-serialization"). The
+ // roundtrip tests (TenantConfigTableSerializationTests) pin that contract, incl. legacy
+ // rows lacking the newer columns.
+ internal static TableEntity ConvertToTenantTableEntity(TenantConfiguration config)
{
var entity = new TableEntity(config.TenantId, "config")
{
@@ -396,6 +400,10 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
{ "EntraAppRolesEnabled", config.EntraAppRolesEnabled },
{ "OnboardedAt", config.OnboardedAt },
{ "PlanTier", config.PlanTier },
+ { "TrialExpiresUtc", config.TrialExpiresUtc },
+ { "TrialStartedUtc", config.TrialStartedUtc },
+ { "TrialConsumed", config.TrialConsumed },
+ { "TrialGrantedBy", config.TrialGrantedBy },
// SLA targets
{ "SlaTargetSuccessRate", config.SlaTargetSuccessRate.HasValue ? (double)config.SlaTargetSuccessRate.Value : (double?)null },
{ "SlaTargetMaxDurationMinutes", config.SlaTargetMaxDurationMinutes },
@@ -412,7 +420,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
return entity;
}
- private TenantConfiguration ConvertFromTenantTableEntity(TableEntity entity)
+ internal static TenantConfiguration ConvertFromTenantTableEntity(TableEntity entity)
{
return new TenantConfiguration
{
@@ -487,6 +495,10 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
EntraAppRolesEnabled = entity.GetBoolean("EntraAppRolesEnabled") ?? false,
OnboardedAt = entity.GetDateTime("OnboardedAt"),
PlanTier = entity.GetString("PlanTier") ?? "free",
+ TrialExpiresUtc = entity.GetDateTime("TrialExpiresUtc"),
+ TrialStartedUtc = entity.GetDateTime("TrialStartedUtc"),
+ TrialConsumed = entity.GetBoolean("TrialConsumed") ?? false,
+ TrialGrantedBy = entity.GetString("TrialGrantedBy"),
// SLA targets
SlaTargetSuccessRate = entity.GetDouble("SlaTargetSuccessRate") != null ? (decimal)entity.GetDouble("SlaTargetSuccessRate")! : null,
SlaTargetMaxDurationMinutes = entity.GetInt32("SlaTargetMaxDurationMinutes"),
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/DelegatedAdminManagementFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/DelegatedAdminManagementFunction.cs
index 13a27a61..3244f90e 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/DelegatedAdminManagementFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/DelegatedAdminManagementFunction.cs
@@ -78,6 +78,11 @@ public class DelegatedAdminManagementFunction
if (role != Constants.DelegatedRoles.DelegatedReader && role != Constants.DelegatedRoles.DelegatedAdmin)
return await Bad(req, $"role must be '{Constants.DelegatedRoles.DelegatedReader}' or '{Constants.DelegatedRoles.DelegatedAdmin}'");
+ // 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
+ // 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.
var entry = await _delegatedAdminService.UpsertAsync(
body.Upn, body.TenantId, role,
Constants.DelegatedStatus.Active, Constants.DelegatedSource.OperatorGranted, currentUpn ?? "");
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetTenantFeatureFlagsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetTenantFeatureFlagsFunction.cs
index a384229d..1d69c93b 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetTenantFeatureFlagsFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetTenantFeatureFlagsFunction.cs
@@ -2,6 +2,7 @@ using System;
using System.Net;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Helpers;
+using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
using Microsoft.Azure.Functions.Worker;
@@ -47,7 +48,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
var config = await _configService.GetConfigurationAsync(requestCtx.TargetTenantId);
var response = req.CreateResponse(HttpStatusCode.OK);
- await response.WriteAsJsonAsync(BuildPayload(config));
+ await response.WriteAsJsonAsync(BuildPayload(config, DateTime.UtcNow));
return response;
}
catch (Exception ex)
@@ -62,23 +63,45 @@ namespace AutopilotMonitor.Functions.Functions.Config
///
/// Projects the member-readable subset of .
/// Pulled out as a static method so unit tests can verify field-level mapping
- /// without standing up an HttpRequestData mock.
+ /// without standing up an HttpRequestData mock. feeds the
+ /// read-time edition resolution (trial expiry degrades automatically).
///
- internal static object BuildPayload(TenantConfiguration config) => new
+ internal static object BuildPayload(TenantConfiguration config, DateTime nowUtc)
{
- bootstrapTokenEnabled = config.BootstrapTokenEnabled,
- // Drives the "Autopilot Device Validation disabled" dashboard banner
- // (useTenantSecurityConfig).
- validateAutopilotDevice = config.ValidateAutopilotDevice,
- // Session-detail UI flags (useSessionTenantConfig). Nullable in the model;
- // surface the agent-side defaults so the UI does not need a second nullable layer.
- 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
- };
+ 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);
+
+ return new
+ {
+ bootstrapTokenEnabled = config.BootstrapTokenEnabled,
+ // Drives the "Autopilot Device Validation disabled" dashboard banner
+ // (useTenantSecurityConfig).
+ validateAutopilotDevice = config.ValidateAutopilotDevice,
+ // Session-detail UI flags (useSessionTenantConfig). Nullable in the model;
+ // surface the agent-side defaults so the UI does not need a second nullable layer.
+ 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,
+ // 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(),
+ isTrial,
+ trialExpiresUtc = isTrial ? config.TrialExpiresUtc : null,
+ trialAvailable = !config.TrialConsumed && edition == TenantEdition.Community,
+ entitlements = new
+ {
+ retentionCapDays = entitlements.RetentionCapDays,
+ userRateLimitPerMinute = entitlements.UserRateLimitPerMinute,
+ delegatedAdminAllowed = entitlements.DelegatedAdminAllowed,
+ mcpUsagePlan = entitlements.McpUsagePlanName
+ }
+ };
+ }
}
}
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/PlanManagementFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/PlanManagementFunction.cs
index dea85581..17b1f0fe 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/PlanManagementFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/PlanManagementFunction.cs
@@ -1,5 +1,7 @@
using System.Net;
using System.Text.Json;
+using AutopilotMonitor.Functions.Helpers;
+using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models.Config;
@@ -9,52 +11,121 @@ using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Config
{
+ ///
+ /// Plan/edition management: GA-only plan+trial mutation (PATCH plan), tenant-admin self-service
+ /// trial (POST trial), and the global usage-plan definitions (SectionUsagePlans). All writes go
+ /// through so the 5-minute config cache is invalidated,
+ /// and every mutation is audited under the target tenant's trail.
+ ///
public class PlanManagementFunction
{
+ /// Self-service trial length. GA can grant arbitrary end dates via PATCH.
+ internal const int SelfServiceTrialDays = 30;
+
private readonly ILogger _logger;
- private readonly IConfigRepository _configRepo;
+ private readonly TenantConfigurationService _configService;
private readonly AdminConfigurationService _adminConfigService;
+ private readonly IMaintenanceRepository _maintenanceRepo;
+ private readonly TimeProvider _time;
public PlanManagementFunction(
ILogger logger,
- IConfigRepository configRepo,
- AdminConfigurationService adminConfigService)
+ TenantConfigurationService configService,
+ AdminConfigurationService adminConfigService,
+ IMaintenanceRepository maintenanceRepo)
+ : this(logger, configService, adminConfigService, maintenanceRepo, TimeProvider.System)
+ {
+ }
+
+ /// Test seam — inject a fake for deterministic trial math.
+ public PlanManagementFunction(
+ ILogger logger,
+ TenantConfigurationService configService,
+ AdminConfigurationService adminConfigService,
+ IMaintenanceRepository maintenanceRepo,
+ TimeProvider time)
{
_logger = logger;
- _configRepo = configRepo;
+ _configService = configService;
_adminConfigService = adminConfigService;
+ _maintenanceRepo = maintenanceRepo;
+ _time = time;
}
///
- /// PATCH /api/config/{tenantId}/plan
- /// Sets the plan tier for a tenant. Body: { "planTier": "pro" }
+ /// PATCH /api/config/{tenantId}/plan — GlobalAdminOnly (catalog-enforced).
+ /// Body: { "planTier"?: "community"|"enterprise", "trialExpiresUtc"?: ISO-8601 | null }.
+ /// 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.
///
[Function("SetTenantPlanTier")]
public async Task SetPlanTier(
[HttpTrigger(AuthorizationLevel.Anonymous, "patch", Route = "config/{tenantId}/plan")] HttpRequestData req,
string tenantId)
{
- _logger.LogInformation("SetTenantPlanTier: tenantId={TenantId}", tenantId);
-
try
{
- var body = await req.ReadFromJsonAsync();
- if (body == null || string.IsNullOrWhiteSpace(body.PlanTier))
+ var requestCtx = req.GetRequestContext();
+ var caller = requestCtx.UserPrincipalName ?? "Unknown";
+ _logger.LogInformation("SetTenantPlanTier: tenantId={TenantId} by {User}", requestCtx.TargetTenantId, caller);
+
+ var body = await req.ReadAsStringAsync() ?? string.Empty;
+ JsonDocument doc;
+ try
{
- var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
- await badRequest.WriteAsJsonAsync(new { error = "planTier is required" });
- return badRequest;
+ doc = JsonDocument.Parse(string.IsNullOrWhiteSpace(body) ? "{}" : body);
+ }
+ catch (JsonException)
+ {
+ return await BadRequestAsync(req, "Invalid JSON body");
}
- var validTiers = new[] { "free", "pro", "enterprise" };
- if (!validTiers.Contains(body.PlanTier.ToLowerInvariant()))
+ string? newPlanTier = null;
+ bool trialProvided = false;
+ DateTime? newTrialExpiresUtc = null;
+
+ using (doc)
{
- var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
- await badRequest.WriteAsJsonAsync(new { error = $"Invalid planTier. Valid values: {string.Join(", ", validTiers)}" });
- return badRequest;
+ if (doc.RootElement.ValueKind != JsonValueKind.Object)
+ return await BadRequestAsync(req, "Body must be a JSON object");
+
+ if (doc.RootElement.TryGetProperty("planTier", out var tierProp))
+ {
+ if (tierProp.ValueKind != JsonValueKind.String)
+ return await BadRequestAsync(req, "planTier must be a string");
+
+ newPlanTier = tierProp.GetString()!.Trim().ToLowerInvariant();
+ if (newPlanTier != FeatureEntitlementCatalog.CommunityTierName &&
+ newPlanTier != FeatureEntitlementCatalog.EnterpriseTierName)
+ {
+ return await BadRequestAsync(req,
+ $"Invalid planTier. Valid values: {FeatureEntitlementCatalog.CommunityTierName}, {FeatureEntitlementCatalog.EnterpriseTierName}");
+ }
+ }
+
+ if (doc.RootElement.TryGetProperty("trialExpiresUtc", out var trialProp))
+ {
+ trialProvided = true;
+ if (trialProp.ValueKind == JsonValueKind.Null)
+ {
+ newTrialExpiresUtc = null; // explicit null = end trial
+ }
+ else if (trialProp.ValueKind == JsonValueKind.String &&
+ trialProp.TryGetDateTime(out var parsed))
+ {
+ newTrialExpiresUtc = parsed.Kind == DateTimeKind.Utc ? parsed : parsed.ToUniversalTime();
+ }
+ else
+ {
+ return await BadRequestAsync(req, "trialExpiresUtc must be an ISO-8601 date-time string or null");
+ }
+ }
}
- var config = await _configRepo.GetTenantConfigurationAsync(tenantId);
+ if (newPlanTier == null && !trialProvided)
+ return await BadRequestAsync(req, "Provide planTier and/or trialExpiresUtc");
+
+ var config = await _configService.GetConfigurationIfExistsAsync(requestCtx.TargetTenantId);
if (config == null)
{
var notFound = req.CreateResponse(HttpStatusCode.NotFound);
@@ -62,11 +133,51 @@ namespace AutopilotMonitor.Functions.Functions.Config
return notFound;
}
- config.PlanTier = body.PlanTier.ToLowerInvariant();
- await _configRepo.SaveTenantConfigurationAsync(config);
+ var nowUtc = _time.GetUtcNow().UtcDateTime;
+ var changes = new Dictionary();
+
+ if (newPlanTier != null && !string.Equals(config.PlanTier, newPlanTier, StringComparison.Ordinal))
+ {
+ changes["PlanTier"] = $"{config.PlanTier} -> {newPlanTier}";
+ config.PlanTier = newPlanTier;
+ }
+
+ if (trialProvided && config.TrialExpiresUtc != newTrialExpiresUtc)
+ {
+ changes["TrialExpiresUtc"] = $"{FormatUtc(config.TrialExpiresUtc)} -> {FormatUtc(newTrialExpiresUtc)}";
+ config.TrialExpiresUtc = newTrialExpiresUtc;
+ if (newTrialExpiresUtc.HasValue)
+ {
+ config.TrialStartedUtc ??= nowUtc;
+ config.TrialGrantedBy = caller;
+ }
+ }
+
+ if (changes.Count > 0)
+ {
+ config.UpdatedBy = caller;
+ await SaveInvalidatingOnFailureAsync(config);
+
+ await _maintenanceRepo.LogAuditEntryAsync(
+ requestCtx.TargetTenantId,
+ "UPDATE",
+ "TenantPlan",
+ requestCtx.TargetTenantId,
+ caller,
+ changes);
+ }
+
+ var effectiveEdition = FeatureEntitlementCatalog.ResolveEdition(config.PlanTier, config.TrialExpiresUtc, nowUtc);
var response = req.CreateResponse(HttpStatusCode.OK);
- await response.WriteAsJsonAsync(new { tenantId, planTier = config.PlanTier });
+ await response.WriteAsJsonAsync(new
+ {
+ tenantId = requestCtx.TargetTenantId,
+ planTier = config.PlanTier,
+ trialExpiresUtc = config.TrialExpiresUtc,
+ trialConsumed = config.TrialConsumed,
+ effectiveEdition = effectiveEdition.ToString().ToLowerInvariant()
+ });
return response;
}
catch (Exception ex)
@@ -78,6 +189,93 @@ namespace AutopilotMonitor.Functions.Functions.Config
}
}
+ ///
+ /// 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.
+ ///
+ [Function("StartTenantTrial")]
+ public async Task StartTrial(
+ [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "config/{tenantId}/trial")] HttpRequestData req,
+ string tenantId)
+ {
+ try
+ {
+ var requestCtx = req.GetRequestContext();
+ var caller = requestCtx.UserPrincipalName ?? "Unknown";
+ _logger.LogInformation("StartTenantTrial: tenantId={TenantId} by {User}", requestCtx.TargetTenantId, caller);
+
+ var config = await _configService.GetConfigurationIfExistsAsync(requestCtx.TargetTenantId);
+ if (config == null)
+ {
+ var notFound = req.CreateResponse(HttpStatusCode.NotFound);
+ await notFound.WriteAsJsonAsync(new { error = "Tenant not found" });
+ return notFound;
+ }
+
+ var nowUtc = _time.GetUtcNow().UtcDateTime;
+
+ if (config.TrialConsumed)
+ {
+ var conflict = req.CreateResponse(HttpStatusCode.Conflict);
+ await conflict.WriteAsJsonAsync(new
+ {
+ error = "TrialAlreadyConsumed",
+ message = "This tenant has already used its one self-service Enterprise trial. Contact support to extend."
+ });
+ return conflict;
+ }
+
+ if (FeatureEntitlementCatalog.ResolveEdition(config.PlanTier, config.TrialExpiresUtc, nowUtc) == TenantEdition.Enterprise)
+ {
+ var conflict = req.CreateResponse(HttpStatusCode.Conflict);
+ await conflict.WriteAsJsonAsync(new
+ {
+ error = "AlreadyEnterprise",
+ message = "This tenant is already on the Enterprise edition."
+ });
+ return conflict;
+ }
+
+ config.TrialStartedUtc = nowUtc;
+ config.TrialExpiresUtc = nowUtc.AddDays(SelfServiceTrialDays);
+ config.TrialConsumed = true;
+ config.TrialGrantedBy = caller;
+ config.UpdatedBy = caller;
+
+ await SaveInvalidatingOnFailureAsync(config);
+
+ await _maintenanceRepo.LogAuditEntryAsync(
+ requestCtx.TargetTenantId,
+ "CREATE",
+ "TenantTrial",
+ requestCtx.TargetTenantId,
+ caller,
+ new Dictionary
+ {
+ { "TrialStartedUtc", FormatUtc(config.TrialStartedUtc) },
+ { "TrialExpiresUtc", FormatUtc(config.TrialExpiresUtc) }
+ });
+
+ var response = req.CreateResponse(HttpStatusCode.OK);
+ await response.WriteAsJsonAsync(new
+ {
+ tenantId = requestCtx.TargetTenantId,
+ trialStartedUtc = config.TrialStartedUtc,
+ trialExpiresUtc = config.TrialExpiresUtc,
+ effectiveEdition = FeatureEntitlementCatalog.EnterpriseTierName
+ });
+ return response;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error starting trial");
+ var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
+ await errorResponse.WriteAsJsonAsync(new { error = "Internal server error" });
+ return errorResponse;
+ }
+ }
+
///
/// GET /api/global/config/plan-tiers
/// Returns plan tier definitions from AdminConfiguration.PlanTierDefinitionsJson.
@@ -89,7 +287,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
try
{
var config = await _adminConfigService.GetConfigurationAsync();
- var tiers = ParsePlanTierDefinitions(config.PlanTierDefinitionsJson);
+ var tiers = PlanTierDefinitionParser.Parse(config.PlanTierDefinitionsJson);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new { tiers });
@@ -117,18 +315,14 @@ namespace AutopilotMonitor.Functions.Functions.Config
var body = await req.ReadFromJsonAsync();
if (body?.Tiers == null || body.Tiers.Count == 0)
{
- var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
- await badRequest.WriteAsJsonAsync(new { error = "At least one tier definition is required" });
- return badRequest;
+ return await BadRequestAsync(req, "At least one tier definition is required");
}
// Validate tier names are unique
var names = body.Tiers.Select(t => t.Name.ToLowerInvariant()).ToList();
if (names.Distinct().Count() != names.Count)
{
- var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
- await badRequest.WriteAsJsonAsync(new { error = "Tier names must be unique" });
- return badRequest;
+ return await BadRequestAsync(req, "Tier names must be unique");
}
// Normalize names to lowercase
@@ -152,25 +346,32 @@ namespace AutopilotMonitor.Functions.Functions.Config
}
}
- private static List ParsePlanTierDefinitions(string? json)
+ ///
+ /// Saves via the service (which invalidates the cache on success). Because the mutated
+ /// instance IS the cached instance, a failed save would otherwise leave the cache holding
+ /// unsaved mutations for up to 5 minutes — invalidate before rethrowing.
+ ///
+ private async Task SaveInvalidatingOnFailureAsync(AutopilotMonitor.Shared.Models.TenantConfiguration config)
{
- if (string.IsNullOrWhiteSpace(json))
- return new List();
-
try
{
- return JsonSerializer.Deserialize>(json,
- new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new List();
+ await _configService.SaveConfigurationAsync(config);
}
catch
{
- return new List();
+ _configService.InvalidateCache(config.TenantId);
+ throw;
}
}
- private class SetPlanTierRequest
+ private static string FormatUtc(DateTime? value)
+ => value?.ToString("yyyy-MM-ddTHH:mm:ssZ") ?? "(none)";
+
+ private static async Task BadRequestAsync(HttpRequestData req, string message)
{
- public string PlanTier { get; set; } = string.Empty;
+ var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
+ await badRequest.WriteAsJsonAsync(new { error = message });
+ return badRequest;
}
private class SetPlanTierDefinitionsRequest
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateTenantConfigurationFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateTenantConfigurationFunction.cs
index bb9ef696..5c49325c 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateTenantConfigurationFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateTenantConfigurationFunction.cs
@@ -175,6 +175,38 @@ namespace AutopilotMonitor.Functions.Functions.Config
// MaxNdjsonPayloadSizeMB is table-only — always preserve existing value
config.MaxNdjsonPayloadSizeMB = existingConfig.MaxNdjsonPayloadSizeMB;
+ // Plan/trial fields are mutable ONLY via the dedicated plan/trial endpoints
+ // (PATCH config/{tenantId}/plan, POST config/{tenantId}/trial). The generic PUT
+ // deserializes the full model, so without this preserve a round-tripped stale
+ // view would silently reset the tenant's edition/trial — for ALL callers, GA included.
+ config.PlanTier = existingConfig.PlanTier;
+ config.TrialExpiresUtc = existingConfig.TrialExpiresUtc;
+ config.TrialStartedUtc = existingConfig.TrialStartedUtc;
+ config.TrialConsumed = existingConfig.TrialConsumed;
+ config.TrialGrantedBy = existingConfig.TrialGrantedBy;
+
+ // Retention cap (edition entitlement): non-GA callers may only set 7..cap days.
+ // Enforced only when the caller actually CHANGED the value, so a tenant whose
+ // stored value predates the cap (e.g. 180 on Community) can still save unrelated
+ // settings. 0 (= infinite) is a GA-only escape hatch. Edition resolves from the
+ // STORED config — the client-sent plan fields were just discarded above.
+ if (!requestCtx.IsGlobalAdmin && config.DataRetentionDays != existingConfig.DataRetentionDays)
+ {
+ var cap = FeatureEntitlementCatalog
+ .Get(TenantEntitlementService.ResolveEdition(existingConfig, DateTime.UtcNow))
+ .RetentionCapDays;
+ if (config.DataRetentionDays < 7 || config.DataRetentionDays > cap)
+ {
+ var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
+ 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."
+ });
+ return badRequest;
+ }
+ }
+
// Save configuration
await _configService.SaveConfigurationAsync(config);
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/AuthFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/AuthFunction.cs
index 4287e9eb..5a943620 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/AuthFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/AuthFunction.cs
@@ -94,10 +94,11 @@ 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);
- var delegatedScopeTask = _delegatedAdminService.GetScopeAsync(upn);
+ // tenantId = JWT tid = the caller's home tenant — gates the delegated (MSP) scope (Enterprise-only seat).
+ var delegatedScopeTask = _delegatedAdminService.GetScopeAsync(upn, tenantId);
var isApprovedTask = _previewWhitelistService.IsApprovedAsync(tenantId);
var membershipTask = _tenantAdminsService.GetTableMembershipAsync(tenantId, upn);
- var mcpCheckTask = _mcpUserService.IsAllowedAsync(upn);
+ var mcpCheckTask = _mcpUserService.IsAllowedAsync(upn, tenantId);
var existingAdminsTask = _tenantAdminsService.GetTenantAdminsAsync(tenantId);
await Task.WhenAll(tenantConfigTask, globalRoleTask, delegatedScopeTask, isApprovedTask,
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/McpUserFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/McpUserFunction.cs
index a18e1d25..c5fd2566 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/McpUserFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/McpUserFunction.cs
@@ -163,7 +163,8 @@ public class McpUserFunction
var principal = context.GetUser();
var upn = principal?.GetUserPrincipalName();
- var result = await _mcpUserService.IsAllowedAsync(upn);
+ // JWT tid = the caller's home tenant — gates the delegated (MSP) auto-grant (Enterprise-only seat).
+ var result = await _mcpUserService.IsAllowedAsync(upn, principal?.GetTenantId());
var response = req.CreateResponse(result.IsAllowed ? HttpStatusCode.OK : HttpStatusCode.Forbidden);
var payload = new Dictionary
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRAddToGroupFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRAddToGroupFunction.cs
index 84fcaac8..94c7df15 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRAddToGroupFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRAddToGroupFunction.cs
@@ -100,8 +100,8 @@ 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.
- var scope = await _delegatedAdminService.GetScopeAsync(userEmail);
+ // the requested tenant only. Home tid gates the MSP seat (Enterprise-only).
+ var scope = await _delegatedAdminService.GetScopeAsync(userEmail, userTenantId);
allowedCrossTenant = scope.RoleFor(requestedTenantId) != null;
if (allowedCrossTenant)
_logger.LogInformation($"Delegated user {userEmail} joining managed cross-tenant group: {request.GroupName}");
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRRemoveFromGroupFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRRemoveFromGroupFunction.cs
index 9b082c05..4f91e31f 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRRemoveFromGroupFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRRemoveFromGroupFunction.cs
@@ -92,7 +92,7 @@ namespace AutopilotMonitor.Functions.Functions.Infrastructure
var allowedCrossTenant = requestCtx.HasGlobalScope;
if (!allowedCrossTenant && !string.IsNullOrEmpty(userEmail))
{
- var scope = await _delegatedAdminService.GetScopeAsync(userEmail);
+ var scope = await _delegatedAdminService.GetScopeAsync(userEmail, userTenantId);
allowedCrossTenant = scope.RoleFor(requestedTenantId) != null;
}
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Maintenance/TrialExpirySweepFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Maintenance/TrialExpirySweepFunction.cs
new file mode 100644
index 00000000..9bfb52f0
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Maintenance/TrialExpirySweepFunction.cs
@@ -0,0 +1,134 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using AutopilotMonitor.Functions.Security;
+using AutopilotMonitor.Functions.Services;
+using AutopilotMonitor.Shared.DataAccess;
+using Microsoft.Azure.Functions.Worker;
+using Microsoft.Extensions.Logging;
+
+namespace AutopilotMonitor.Functions.Functions.Maintenance;
+
+///
+/// Daily informational sweep over tenant Enterprise 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:
+///
+/// TenantTrialExpired (Warning) — the trial ended within the last sweep window
+/// (24h look-back matching the daily cadence).
+/// TenantTrialExpiring (Info) — the trial ends within the next
+/// days (re-emitted each daily run until expiry —
+/// acceptable for an Info-tier heads-up, no dedupe state to maintain).
+///
+/// Tenants whose stored PlanTier is already the permanent "enterprise" are skipped —
+/// their trial timestamps are inert leftovers and expiry changes nothing.
+/// Failure never poisons the timer; the next run re-sweeps.
+///
+public class TrialExpirySweepFunction
+{
+ /// Heads-up window for TenantTrialExpiring.
+ public static readonly TimeSpan ExpiringHeadsUp = TimeSpan.FromDays(3);
+ private const int ExpiringHeadsUpDays = 3;
+
+ /// Look-back for TenantTrialExpired — matches the daily cadence so no expiry is skipped.
+ public static readonly TimeSpan ExpiredLookBack = TimeSpan.FromHours(24);
+
+ /// 03:30 UTC daily — off the busy maintenance window, after ScriptNameCacheCleanup (03:00).
+ private const string Cron = "0 30 3 * * *";
+
+ private readonly IConfigRepository _configRepo;
+ private readonly OpsEventService _opsEvents;
+ private readonly ILogger _logger;
+ private readonly TimeProvider _time;
+
+ public TrialExpirySweepFunction(
+ IConfigRepository configRepo,
+ OpsEventService opsEvents,
+ ILogger logger)
+ : this(configRepo, opsEvents, logger, TimeProvider.System)
+ {
+ }
+
+ /// Test seam — inject a fake for deterministic window math.
+ internal TrialExpirySweepFunction(
+ IConfigRepository configRepo,
+ OpsEventService opsEvents,
+ ILogger logger,
+ TimeProvider time)
+ {
+ _configRepo = configRepo;
+ _opsEvents = opsEvents;
+ _logger = logger;
+ _time = time;
+ }
+
+ [Function("TrialExpirySweep")]
+ public async Task Run([TimerTrigger(Cron)] object timer, CancellationToken cancellationToken)
+ {
+ await RunCoreAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ /// Testable core — bypasses TimerInfo / FunctionContext.
+ internal async Task RunCoreAsync(CancellationToken ct)
+ {
+ var now = _time.GetUtcNow().UtcDateTime;
+ var result = new SweepResult();
+
+ System.Collections.Generic.List configs;
+ try
+ {
+ configs = await _configRepo.GetAllTenantConfigurationsAsync().ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ // Informational sweep — never poison the timer; the next daily run re-sweeps.
+ _logger.LogError(ex, "TrialExpirySweep: failed to load tenant configurations — skipping this run");
+ return result;
+ }
+
+ foreach (var config in configs)
+ {
+ ct.ThrowIfCancellationRequested();
+
+ 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))
+ continue;
+
+ result.TrialsSeen++;
+
+ if (expiry <= now)
+ {
+ if (expiry > now - ExpiredLookBack)
+ {
+ await _opsEvents.RecordTenantTrialExpiredAsync(config.TenantId, config.DomainName, expiry)
+ .ConfigureAwait(false);
+ result.ExpiredEmitted++;
+ }
+ // Older expiries were reported by a previous run — stay silent.
+ }
+ else if (expiry <= now + ExpiringHeadsUp)
+ {
+ var daysLeft = Math.Max(1, (int)Math.Ceiling((expiry - now).TotalDays));
+ await _opsEvents.RecordTenantTrialExpiringAsync(config.TenantId, config.DomainName, expiry, daysLeft)
+ .ConfigureAwait(false);
+ result.ExpiringEmitted++;
+ }
+ }
+
+ _logger.LogInformation(
+ "TrialExpirySweep completed: trialsSeen={TrialsSeen} expiredEmitted={ExpiredEmitted} expiringEmitted={ExpiringEmitted}",
+ result.TrialsSeen, result.ExpiredEmitted, result.ExpiringEmitted);
+ return result;
+ }
+
+ /// Summary counters returned from so tests can assert outcomes.
+ internal sealed class SweepResult
+ {
+ public int TrialsSeen { get; set; }
+ public int ExpiredEmitted { get; set; }
+ public int ExpiringEmitted { get; set; }
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/McpUsageMetricsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/McpUsageMetricsFunction.cs
index bb6b5c97..ed08d292 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/McpUsageMetricsFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/McpUsageMetricsFunction.cs
@@ -19,15 +19,18 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
private readonly ILogger _logger;
private readonly IUserUsageRepository _userUsageRepo;
private readonly McpUserService _mcpUserService;
+ private readonly McpQuotaService _quotaService;
public McpUsageMetricsFunction(
ILogger logger,
IUserUsageRepository userUsageRepo,
- McpUserService mcpUserService)
+ McpUserService mcpUserService,
+ McpQuotaService quotaService)
{
_logger = logger;
_userUsageRepo = userUsageRepo;
_mcpUserService = mcpUserService;
+ _quotaService = quotaService;
}
///
@@ -57,12 +60,26 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
var records = await _userUsageRepo.GetUsageByUserAsync(userId, dateFrom, dateTo);
var mcpUser = !string.IsNullOrEmpty(upn) ? await _mcpUserService.GetMcpUserAsync(upn) : null;
+ // Effective quota state: resolved plan (per-user override → tenant edition),
+ // limits (SectionUsagePlans definition → catalog fallback) and current counters.
+ var tenantId = principal?.GetTenantId();
+ var quota = await _quotaService.CheckAsync(userId, upn, tenantId);
+
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
{
userId,
upn,
usagePlan = mcpUser?.UsagePlan,
+ effectivePlan = quota.Plan,
+ quota = new
+ {
+ dailyLimit = quota.DailyLimit,
+ monthlyLimit = quota.MonthlyLimit,
+ dailyUsed = quota.DailyUsed,
+ monthlyUsed = quota.MonthlyUsed,
+ resetUtc = quota.ResetUtc
+ },
records
});
return response;
diff --git a/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigProjection.cs b/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigProjection.cs
index 419a9a20..d9732370 100644
--- a/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigProjection.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigProjection.cs
@@ -22,6 +22,8 @@ namespace AutopilotMonitor.Functions.Helpers
("tenantId", c => c.TenantId),
("domainName", c => c.DomainName),
("planTier", c => c.PlanTier),
+ ("trialExpiresUtc", c => c.TrialExpiresUtc),
+ ("trialConsumed", c => c.TrialConsumed),
("disabled", c => c.Disabled),
("disabledReason", c => c.DisabledReason),
("onboardedAt", c => c.OnboardedAt),
diff --git a/src/Backend/AutopilotMonitor.Functions/Middleware/McpQuotaEnforcementMiddleware.cs b/src/Backend/AutopilotMonitor.Functions/Middleware/McpQuotaEnforcementMiddleware.cs
new file mode 100644
index 00000000..15a5890c
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions/Middleware/McpQuotaEnforcementMiddleware.cs
@@ -0,0 +1,125 @@
+using System.Net;
+using AutopilotMonitor.Functions.Extensions;
+using AutopilotMonitor.Functions.Helpers;
+using AutopilotMonitor.Functions.Services;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Azure.Functions.Worker;
+using Microsoft.Azure.Functions.Worker.Middleware;
+using Microsoft.Extensions.Logging;
+
+namespace AutopilotMonitor.Functions.Middleware;
+
+///
+/// Enforces the per-user MCP daily/monthly request quota. Runs after
+/// (per-minute burst control) — this is the budget layer on
+/// top. Applies ONLY to HTTP requests marked X-Client-Source: mcp with an authenticated
+/// principal (oid); everything else passes through untouched. Global Admins are exempt.
+///
+/// Over-quota requests get 429 with a structured body, Retry-After , and
+/// X-MCP-Quota-* headers; allowed MCP requests get the quota headers too so the MCP
+/// client can surface remaining budget. Fail-open on storage/counter errors (handled inside
+/// ), fail-closed on plan resolution (unknown plan → Community).
+///
+public class McpQuotaEnforcementMiddleware : IFunctionsWorkerMiddleware
+{
+ private readonly McpQuotaService _quotaService;
+ private readonly ILogger _logger;
+
+ public McpQuotaEnforcementMiddleware(
+ McpQuotaService quotaService,
+ ILogger logger)
+ {
+ _quotaService = quotaService;
+ _logger = logger;
+ }
+
+ public async Task Invoke(FunctionContext context, FunctionExecutionDelegate next)
+ {
+ var httpContext = context.GetHttpContext();
+ if (httpContext == null)
+ {
+ await next(context);
+ return;
+ }
+
+ var isMcpRequest = string.Equals(
+ httpContext.Request.Headers["X-Client-Source"].FirstOrDefault(), "mcp", StringComparison.OrdinalIgnoreCase);
+ if (!isMcpRequest)
+ {
+ await next(context);
+ return;
+ }
+
+ var principal = context.GetUser();
+ var oid = principal?.GetObjectId();
+ if (string.IsNullOrEmpty(oid))
+ {
+ // Unauthenticated MCP probe — auth middleware / policy enforcement handle rejection.
+ await next(context);
+ return;
+ }
+
+ // GA exempt: platform operators are not customers of the quota.
+ var requestContext = context.GetRequestContext();
+ if (requestContext.IsGlobalAdmin)
+ {
+ await next(context);
+ return;
+ }
+
+ McpQuotaDecision decision;
+ try
+ {
+ var upn = principal!.GetUserPrincipalName();
+ var tenantId = principal.GetTenantId();
+ decision = await _quotaService.CheckAsync(oid, upn, tenantId);
+ }
+ catch (Exception ex)
+ {
+ // Belt-and-braces fail-open: the quota layer must never take MCP down.
+ _logger.LogError(ex, "[McpQuota] Quota check threw for oid={Oid} — allowing request (fail-open)", oid);
+ await next(context);
+ return;
+ }
+
+ StampQuotaHeaders(httpContext, decision);
+
+ if (decision.Allowed)
+ {
+ await next(context);
+ return;
+ }
+
+ var retryAfterSeconds = Math.Max(1, (int)(decision.ResetUtc - DateTime.UtcNow).TotalSeconds);
+ _logger.LogWarning(
+ "[McpQuota] BLOCKED oid={Oid} plan={Plan} scope={Scope} daily={DailyUsed}/{DailyLimit} monthly={MonthlyUsed}/{MonthlyLimit}",
+ oid, decision.Plan, decision.Scope, decision.DailyUsed, decision.DailyLimit, decision.MonthlyUsed, decision.MonthlyLimit);
+
+ httpContext.Response.StatusCode = (int)HttpStatusCode.TooManyRequests;
+ httpContext.Response.Headers["Retry-After"] = retryAfterSeconds.ToString();
+ httpContext.Response.ContentType = "application/json";
+ await httpContext.Response.WriteAsJsonAsync(new
+ {
+ quotaExceeded = true,
+ plan = decision.Plan,
+ scope = decision.Scope,
+ limit = decision.Scope == "monthly" ? decision.MonthlyLimit : decision.DailyLimit,
+ used = decision.Scope == "monthly" ? decision.MonthlyUsed : decision.DailyUsed,
+ resetUtc = decision.ResetUtc.ToString("yyyy-MM-ddTHH:mm:ssZ"),
+ message = $"MCP {decision.Scope} request quota exceeded for plan '{decision.Plan}'. Resets at {decision.ResetUtc:yyyy-MM-ddTHH:mm:ss}Z."
+ });
+ }
+
+ private static void StampQuotaHeaders(HttpContext httpContext, McpQuotaDecision decision)
+ {
+ // Direct-write pattern — same as UserRateLimitMiddleware's X-RateLimit-* headers.
+ httpContext.Response.Headers["X-MCP-Quota-Plan"] = decision.Plan;
+ httpContext.Response.Headers["X-MCP-Quota-Daily-Limit"] = decision.DailyLimit.ToString();
+ httpContext.Response.Headers["X-MCP-Quota-Monthly-Limit"] = decision.MonthlyLimit.ToString();
+ if (decision.DailyUsed >= 0)
+ {
+ httpContext.Response.Headers["X-MCP-Quota-Daily-Used"] = decision.DailyUsed.ToString();
+ httpContext.Response.Headers["X-MCP-Quota-Monthly-Used"] = decision.MonthlyUsed.ToString();
+ }
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions/Middleware/PolicyEnforcementMiddleware.cs b/src/Backend/AutopilotMonitor.Functions/Middleware/PolicyEnforcementMiddleware.cs
index 6a2f8096..1e9fd71e 100644
--- a/src/Backend/AutopilotMonitor.Functions/Middleware/PolicyEnforcementMiddleware.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Middleware/PolicyEnforcementMiddleware.cs
@@ -149,7 +149,8 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
var isScopedRoute = catalogEntry.TenantScoping is TenantScoping.RouteParam or TenantScoping.QueryParam;
if (!hasGlobalScope && !string.IsNullOrEmpty(upn) && isScopedRoute && crossTenant)
{
- var scope = await _delegatedAdminService.GetScopeAsync(upn);
+ // Home-tenant (JWT tid) gates the delegated scope: MSP seats require an Enterprise home tenant.
+ var scope = await _delegatedAdminService.GetScopeAsync(upn, jwtTenantId);
delegatedRole = scope.RoleFor(namedTarget);
if (delegatedRole != null)
allowedTenantIds = scope.TenantIds;
@@ -314,7 +315,7 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
return await EvaluateGlobalReadOrAdminAsync(upn, userIdentifier);
case EndpointPolicy.GlobalReadOrDelegatedSubset:
- return await EvaluateGlobalReadOrDelegatedSubsetAsync(upn, userIdentifier);
+ return await EvaluateGlobalReadOrDelegatedSubsetAsync(tenantId, upn, userIdentifier);
case EndpointPolicy.GlobalAdminOnly:
return await EvaluateGlobalAdminOnlyAsync(upn, userIdentifier);
@@ -513,7 +514,7 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
/// denied. Used by aggregate endpoints whose body can be filtered per tenant (e.g. config/all).
///
private async Task EvaluateGlobalReadOrDelegatedSubsetAsync(
- string? upn, string userIdentifier)
+ string? jwtTenantId, string? upn, string userIdentifier)
{
if (string.IsNullOrEmpty(upn))
return CatalogDecisionResult.Deny(userIdentifier, "N/A", "MissingClaims");
@@ -522,7 +523,8 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
if (globalRole != null)
return CatalogDecisionResult.Allow(userIdentifier, globalRole, "GlobalScope");
- var scope = await _delegatedAdminService.GetScopeAsync(upn);
+ // Home-tenant (JWT tid) gates the delegated scope: MSP seats require an Enterprise home tenant.
+ var scope = await _delegatedAdminService.GetScopeAsync(upn, jwtTenantId);
if (!scope.IsEmpty)
return CatalogDecisionResult.Allow(
userIdentifier, Constants.DelegatedRoles.DelegatedReader, "DelegatedSubset",
diff --git a/src/Backend/AutopilotMonitor.Functions/Middleware/UserRateLimitMiddleware.cs b/src/Backend/AutopilotMonitor.Functions/Middleware/UserRateLimitMiddleware.cs
index 74a6b9e3..af839482 100644
--- a/src/Backend/AutopilotMonitor.Functions/Middleware/UserRateLimitMiddleware.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Middleware/UserRateLimitMiddleware.cs
@@ -18,17 +18,20 @@ public class UserRateLimitMiddleware : IFunctionsWorkerMiddleware
private readonly RateLimitService _rateLimitService;
private readonly AdminConfigurationService _adminConfigService;
private readonly TenantConfigurationService _tenantConfigService;
+ private readonly TenantEntitlementService _entitlementService;
private readonly ILogger _logger;
public UserRateLimitMiddleware(
RateLimitService rateLimitService,
AdminConfigurationService adminConfigService,
TenantConfigurationService tenantConfigService,
+ TenantEntitlementService entitlementService,
ILogger logger)
{
_rateLimitService = rateLimitService;
_adminConfigService = adminConfigService;
_tenantConfigService = tenantConfigService;
+ _entitlementService = entitlementService;
_logger = logger;
}
@@ -65,20 +68,33 @@ public class UserRateLimitMiddleware : IFunctionsWorkerMiddleware
// default row. A valid JWT from an unregistered tenant hitting a read/self-service endpoint must
// never materialize a tenant config row. No row → inherit global.
int? tenantOverride = null;
+ int? entitlementFloor = null;
if (!requestContext.HasGlobalScope && !string.IsNullOrEmpty(requestContext.TargetTenantId))
{
var tenantConfig = await _tenantConfigService.GetConfigurationIfExistsAsync(requestContext.TargetTenantId);
tenantOverride = tenantConfig?.CustomUserRateLimitRequestsPerMinute;
}
+ // Edition entitlement floor (Enterprise: 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
+ // same 5-minute config cache. Global-scope callers have their own budgets — no floor.
+ if (!requestContext.HasGlobalScope && !string.IsNullOrEmpty(requestContext.TenantId))
+ {
+ var entitlements = await _entitlementService.GetEntitlementsAsync(requestContext.TenantId);
+ entitlementFloor = entitlements.UserRateLimitPerMinute;
+ }
+
// Base limit: Global Admins get the GA budget; everyone else (standard users AND read-only
// Global Readers) gets the standard user default (with the tenant override applied above only
- // for tenant-scoped callers).
+ // for tenant-scoped callers, raised to the edition floor when no override is set).
var limit = RateLimitResolver.ResolveUserLimit(
requestContext.IsGlobalAdmin,
tenantOverride,
config.UserRateLimitRequestsPerMinute,
- config.GlobalAdminRateLimitRequestsPerMinute);
+ config.GlobalAdminRateLimitRequestsPerMinute,
+ entitlementFloor);
var key = $"user_ratelimit_{requestContext.UserPrincipalName.ToLowerInvariant()}";
result = _rateLimitService.CheckRateLimit(key, limit);
diff --git a/src/Backend/AutopilotMonitor.Functions/Program.cs b/src/Backend/AutopilotMonitor.Functions/Program.cs
index 97a616f3..fd11485a 100644
--- a/src/Backend/AutopilotMonitor.Functions/Program.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Program.cs
@@ -41,6 +41,10 @@ builder.UseMiddleware();
builder.UseMiddleware();
builder.UseMiddleware();
builder.UseMiddleware();
+// MCP quota (daily/monthly budget on top of the per-minute rate limit): only requests marked
+// X-Client-Source: mcp with an authenticated principal are checked; GA exempt; fail-open on
+// counter errors, fail-closed on plan resolution. See McpQuotaEnforcementMiddleware.
+builder.UseMiddleware();
// After policy enforcement (RequestContext resolved): record best-effort "last seen" presence for
// authenticated web users so a Global Admin can see who is active right now. Throttled + fail-open.
builder.UseMiddleware();
@@ -214,6 +218,8 @@ builder.Services.AddHostedService(); // Initialize all
builder.Services.AddTableStorageDataAccess();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
diff --git a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs
index 4865d0cc..50b60db2 100644
--- a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs
@@ -463,6 +463,9 @@ 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).
+ 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),
new("GET", "feedback/all", EndpointPolicy.GlobalReadOrAdmin),
diff --git a/src/Backend/AutopilotMonitor.Functions/Security/FeatureEntitlementCatalog.cs b/src/Backend/AutopilotMonitor.Functions/Security/FeatureEntitlementCatalog.cs
new file mode 100644
index 00000000..5cce156d
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions/Security/FeatureEntitlementCatalog.cs
@@ -0,0 +1,119 @@
+using System;
+
+namespace AutopilotMonitor.Functions.Security
+{
+ ///
+ /// 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.
+ ///
+ public enum TenantEdition
+ {
+ Community,
+ Enterprise
+ }
+
+ ///
+ /// The static per-edition entitlement values. Immutable code catalog — analogous to
+ /// : entitlements are defined here, not in storage,
+ /// so a storage failure can never widen access. MCP daily/monthly limits are FALLBACKS only;
+ /// admin-edited SectionUsagePlans rows (AdminConfiguration.PlanTierDefinitionsJson) take
+ /// precedence when a matching plan name exists.
+ ///
+ public sealed class EditionEntitlements
+ {
+ public TenantEdition Edition { get; init; }
+
+ /// Maximum data retention a tenant admin may configure (days).
+ public int RetentionCapDays { get; init; }
+
+ ///
+ /// Entitlement floor for the per-user (portal/JWT) rate limit, requests/minute.
+ /// Null = no floor (the AdminConfiguration default applies unchanged). When set, the
+ /// effective limit is raised to at least this value — it never lowers an admin-raised
+ /// default, and an explicit per-tenant override set by a Global Admin still wins.
+ ///
+ public int? UserRateLimitPerMinute { get; init; }
+
+ ///
+ /// Entitlement floor for the per-device (agent/cert) rate limit, requests/minute.
+ /// Same semantics as .
+ ///
+ public int? DeviceRateLimitPerMinute { get; init; }
+
+ ///
+ /// 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).
+ /// Enforced at resolve time in DelegatedAdminService.GetScopeAsync.
+ ///
+ public bool DelegatedAdminAllowed { get; init; }
+
+ /// Default MCP usage plan name for users of this tenant (per-user override wins).
+ public string McpUsagePlanName { get; init; } = string.Empty;
+
+ /// Fallback MCP daily request limit when no SectionUsagePlans row matches the plan name.
+ public int McpDailyRequestLimit { get; init; }
+
+ /// Fallback MCP monthly request limit when no SectionUsagePlans row matches the plan name.
+ public int McpMonthlyRequestLimit { get; init; }
+ }
+
+ ///
+ /// 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",
+ /// unknown — is Community (fail-closed, no data migration required).
+ ///
+ public static class FeatureEntitlementCatalog
+ {
+ /// Write-side canonical tier names (the only values the plan endpoint accepts).
+ public const string CommunityTierName = "community";
+ public const string EnterpriseTierName = "enterprise";
+
+ private static readonly EditionEntitlements Community = new()
+ {
+ Edition = TenantEdition.Community,
+ RetentionCapDays = 90,
+ UserRateLimitPerMinute = null, // AdminConfiguration default (120) applies
+ DeviceRateLimitPerMinute = null, // AdminConfiguration default (100) applies
+ DelegatedAdminAllowed = false,
+ McpUsagePlanName = CommunityTierName,
+ McpDailyRequestLimit = 100,
+ McpMonthlyRequestLimit = 3000
+ };
+
+ private static readonly EditionEntitlements Enterprise = new()
+ {
+ Edition = TenantEdition.Enterprise,
+ RetentionCapDays = 365,
+ UserRateLimitPerMinute = 150,
+ DeviceRateLimitPerMinute = 150,
+ DelegatedAdminAllowed = true,
+ McpUsagePlanName = EnterpriseTierName,
+ McpDailyRequestLimit = 1000,
+ McpMonthlyRequestLimit = 20000
+ };
+
+ ///
+ /// Resolves the effective edition from the stored plan tier and trial expiry.
+ /// Enterprise ⇔ exact tier "enterprise" (case-insensitive) OR trialExpiresUtc > nowUtc.
+ /// A trial expiring exactly at is already over (strict >).
+ ///
+ public static TenantEdition ResolveEdition(string? planTier, DateTime? trialExpiresUtc, DateTime nowUtc)
+ {
+ if (string.Equals(planTier?.Trim(), EnterpriseTierName, StringComparison.OrdinalIgnoreCase))
+ return TenantEdition.Enterprise;
+
+ if (trialExpiresUtc.HasValue && trialExpiresUtc.Value > nowUtc)
+ return TenantEdition.Enterprise;
+
+ return TenantEdition.Community;
+ }
+
+ /// Returns the entitlement set for an edition. Unknown enum values → Community (fail-closed).
+ public static EditionEntitlements Get(TenantEdition edition) =>
+ edition == TenantEdition.Enterprise ? Enterprise : Community;
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs b/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs
index ce2d36a6..e1cc5ae0 100644
--- a/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs
@@ -72,17 +72,24 @@ namespace AutopilotMonitor.Functions.Security
///
/// Resolves the effective device (agent/cert) rate limit for a tenant:
- /// the per-tenant override if set, otherwise the global default. The global
- /// AdminConfiguration read is served from a 5-minute in-memory cache.
+ /// the per-tenant override if set, otherwise the global default raised to the tenant
+ /// edition's entitlement floor (Enterprise: 150/min — see FeatureEntitlementCatalog).
+ /// The global AdminConfiguration read is served from a 5-minute in-memory cache.
///
private async Task ResolveDeviceRateLimitAsync(TenantConfiguration config)
{
// The global read is served from a 5-minute in-memory cache (O(1) dictionary hit on the
// hot path), so we always take it rather than branching on the override.
var adminConfig = await _adminConfigService.GetConfigurationAsync();
+ // Edition resolves purely from the config already in hand — no extra I/O on the
+ // agent hot path. Fail-closed by construction (unknown tier → Community → null floor).
+ var entitlementFloor = FeatureEntitlementCatalog
+ .Get(Services.TenantEntitlementService.ResolveEdition(config, DateTime.UtcNow))
+ .DeviceRateLimitPerMinute;
return RateLimitResolver.ResolveDeviceLimit(
config.CustomRateLimitRequestsPerMinute,
- adminConfig.GlobalRateLimitRequestsPerMinute);
+ adminConfig.GlobalRateLimitRequestsPerMinute,
+ entitlementFloor);
}
///
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/DelegatedAdminService.cs b/src/Backend/AutopilotMonitor.Functions/Services/DelegatedAdminService.cs
index 0bed49cd..a8f3fa1a 100644
--- a/src/Backend/AutopilotMonitor.Functions/Services/DelegatedAdminService.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Services/DelegatedAdminService.cs
@@ -12,13 +12,16 @@ namespace AutopilotMonitor.Functions.Services;
/// SUBSET of tenants: exactly the tenants for which it holds an Active, enabled DelegatedAdmins row.
/// Externally this is surfaced as "MSP mode".
///
-/// The scope is keyed on UPN and is tid-agnostic — identical to :
-/// the caller signs into their own home tenant, and their cross-tenant reach is resolved from this table
-/// regardless of the JWT's tid. Resolution is cached for 5 minutes; every mutation invalidates the cache.
+/// The scope is keyed on UPN; assignment RESOLUTION is tid-agnostic (identical to ),
+/// 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
+/// be any edition. Resolution is cached briefly (UPN-keyed, gate applied per call); every mutation
+/// invalidates the cache.
///
public class DelegatedAdminService
{
private readonly IAdminRepository _adminRepo;
+ private readonly TenantEntitlementService _entitlementService;
private readonly IMemoryCache _cache;
private readonly ILogger _logger;
// Per-process cache: on scaled-out Flex Consumption, the scope invalidation in UpsertAsync/revoke
@@ -30,10 +33,12 @@ public class DelegatedAdminService
public DelegatedAdminService(
IAdminRepository adminRepo,
+ TenantEntitlementService entitlementService,
IMemoryCache cache,
ILogger logger)
{
_adminRepo = adminRepo;
+ _entitlementService = entitlementService;
_cache = cache;
_logger = logger;
}
@@ -43,8 +48,18 @@ public class DelegatedAdminService
/// tenant. Only + enabled rows with a recognized role
/// contribute; pending/revoked/disabled/unknown-role rows are ignored (fail-closed). Cached briefly (see _cacheDuration).
/// Returns an empty (never null) scope for a UPN with no effective assignments.
+ ///
+ /// Entitlement gate (single choke-point for ALL delegated-scope consumers): delegated ("MSP")
+ /// access is an Enterprise capability of the caller's HOME tenant — the tenant that pays for the MSP
+ /// seat ( = 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
+ /// 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.
+ ///
///
- public virtual async Task GetScopeAsync(string? upn)
+ public virtual async Task GetScopeAsync(string? upn, string? callerHomeTenantId = null)
{
if (string.IsNullOrWhiteSpace(upn))
return DelegatedScope.Empty;
@@ -52,7 +67,7 @@ public class DelegatedAdminService
upn = upn.ToLowerInvariant();
var cacheKey = $"delegated-scope:{upn}";
if (_cache.TryGetValue(cacheKey, out var cached) && cached != null)
- return cached;
+ return await ApplyHomeTenantGateAsync(cached, upn, callerHomeTenantId);
var rows = await _adminRepo.GetDelegatedTenantsAsync(upn);
var tenantRoles = new Dictionary(StringComparer.OrdinalIgnoreCase);
@@ -98,9 +113,34 @@ 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
+ // 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);
_cache.Set(cacheKey, scope, _cacheDuration);
- return scope;
+ return await ApplyHomeTenantGateAsync(scope, upn, callerHomeTenantId);
+ }
+
+ ///
+ /// Empties a non-empty scope when the caller's home tenant is not Enterprise. 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.
+ ///
+ private async Task ApplyHomeTenantGateAsync(DelegatedScope scope, string upn, string? callerHomeTenantId)
+ {
+ if (scope.IsEmpty)
+ return scope;
+
+ if (await _entitlementService.GetEditionAsync(callerHomeTenantId) == Security.TenantEdition.Enterprise)
+ return scope;
+
+ _logger.LogInformation(
+ "[DelegatedAdmin] Suppressing delegated scope for {Upn} — home tenant {HomeTenantId} is not Enterprise (MSP seat requires Enterprise)",
+ upn, string.IsNullOrWhiteSpace(callerHomeTenantId) ? "(unknown)" : callerHomeTenantId);
+ return DelegatedScope.Empty;
}
/// Creates or replaces an assignment, then invalidates the UPN's cached scope.
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Deletion/SessionRetentionFanoutService.cs b/src/Backend/AutopilotMonitor.Functions/Services/Deletion/SessionRetentionFanoutService.cs
index 55fb02ab..f14923d9 100644
--- a/src/Backend/AutopilotMonitor.Functions/Services/Deletion/SessionRetentionFanoutService.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Services/Deletion/SessionRetentionFanoutService.cs
@@ -136,7 +136,22 @@ namespace AutopilotMonitor.Functions.Services.Deletion
private async Task RunForTenantAsync(string tenantId, FanoutResult result, CancellationToken cancellationToken)
{
var config = await _tenantConfig.GetConfigurationAsync(tenantId).ConfigureAwait(false);
- var retentionDays = config?.DataRetentionDays ?? 90;
+ // 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
+ // 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).
+ var storedDays = config?.DataRetentionDays ?? 90;
+ var retentionDays = config == null
+ ? 90
+ : TenantEntitlementService.GetEffectiveRetentionDays(config, DateTime.UtcNow);
+ if (retentionDays > 0 && retentionDays < storedDays)
+ {
+ _logger.LogWarning(
+ "Tenant {TenantId}: DataRetentionDays={Stored} exceeds the {Edition}-edition cap — enforcing {Effective} days",
+ tenantId, storedDays,
+ TenantEntitlementService.ResolveEdition(config!, DateTime.UtcNow), retentionDays);
+ }
if (retentionDays <= 0)
{
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/McpQuotaService.cs b/src/Backend/AutopilotMonitor.Functions/Services/McpQuotaService.cs
new file mode 100644
index 00000000..7d84a2d7
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions/Services/McpQuotaService.cs
@@ -0,0 +1,205 @@
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using AutopilotMonitor.Functions.Security;
+using AutopilotMonitor.Shared.DataAccess;
+using AutopilotMonitor.Shared.Models.Config;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Logging;
+
+namespace AutopilotMonitor.Functions.Services
+{
+ ///
+ /// Resolves and enforces the per-user MCP request quota (daily + monthly).
+ ///
+ /// Plan precedence: explicit per-user override (McpUsers.UsagePlan) → the caller's home-tenant
+ /// edition default (FeatureEntitlementCatalog.McpUsagePlanName). Limits come from the
+ /// admin-editable SectionUsagePlans definitions (AdminConfiguration.PlanTierDefinitionsJson);
+ /// when no definition matches the plan name, the static catalog fallbacks apply. An override
+ /// naming a plan that exists nowhere resolves to the Community fallback (fail-closed).
+ ///
+ /// Counters ride on the existing UserUsage table (written fire-and-forget by
+ /// AuthenticationMiddleware for X-Client-Source: mcp requests): one partition-bounded query
+ /// per check covering the current month; daily = today's rows, monthly = the sum. The
+ /// decision is cached per instance for 60 seconds, so the worst-case overshoot is bounded
+ /// (limit + 60s × request-rate) — the same posture as the sliding-window rate limiter.
+ ///
+ public class McpQuotaService
+ {
+ private static readonly TimeSpan CacheDuration = TimeSpan.FromSeconds(60);
+
+ private readonly IUserUsageRepository _usageRepo;
+ private readonly McpUserService _mcpUserService;
+ private readonly AdminConfigurationService _adminConfigService;
+ private readonly TenantEntitlementService _entitlementService;
+ private readonly IMemoryCache _cache;
+ private readonly ILogger _logger;
+ private readonly TimeProvider _time;
+
+ public McpQuotaService(
+ IUserUsageRepository usageRepo,
+ McpUserService mcpUserService,
+ AdminConfigurationService adminConfigService,
+ TenantEntitlementService entitlementService,
+ IMemoryCache cache,
+ ILogger logger)
+ : this(usageRepo, mcpUserService, adminConfigService, entitlementService, cache, logger, TimeProvider.System)
+ {
+ }
+
+ /// Test seam — inject a fake for deterministic window math.
+ public McpQuotaService(
+ IUserUsageRepository usageRepo,
+ McpUserService mcpUserService,
+ AdminConfigurationService adminConfigService,
+ TenantEntitlementService entitlementService,
+ IMemoryCache cache,
+ ILogger logger,
+ TimeProvider time)
+ {
+ _usageRepo = usageRepo;
+ _mcpUserService = mcpUserService;
+ _adminConfigService = adminConfigService;
+ _entitlementService = entitlementService;
+ _cache = cache;
+ _logger = logger;
+ _time = time;
+ }
+
+ ///
+ /// Resolves the caller's effective plan + limits and checks the current usage against them.
+ /// Fail-open on counter/storage errors (a broken quota check must not take down MCP);
+ /// fail-closed on plan resolution (unknown plan → Community limits).
+ ///
+ public virtual async Task CheckAsync(string oid, string? upn, string? tenantId)
+ {
+ var cacheKey = $"mcp-quota:{oid}";
+ if (_cache.TryGetValue(cacheKey, out var cached) && cached != null)
+ return cached;
+
+ var (planName, dailyLimit, monthlyLimit) = await ResolvePlanAsync(upn, tenantId);
+
+ var nowUtc = _time.GetUtcNow().UtcDateTime;
+ long dailyUsed = 0, monthlyUsed = 0;
+ try
+ {
+ var monthStart = new DateTime(nowUtc.Year, nowUtc.Month, 1, 0, 0, 0, DateTimeKind.Utc);
+ var today = nowUtc.ToString("yyyyMMdd");
+ var records = await _usageRepo.GetUsageByUserAsync(
+ oid, monthStart.ToString("yyyyMMdd"), today);
+
+ monthlyUsed = records.Sum(r => r.RequestCount);
+ dailyUsed = records.Where(r => r.Date == today).Sum(r => r.RequestCount);
+ }
+ catch (Exception ex)
+ {
+ // Fail-open: usage counters unavailable → allow. Do NOT cache the fail-open
+ // decision — the next request retries the read.
+ _logger.LogWarning(ex, "[McpQuota] Usage lookup failed for oid={Oid} — allowing (fail-open)", oid);
+ return McpQuotaDecision.FailOpen(planName, dailyLimit, monthlyLimit);
+ }
+
+ var decision = BuildDecision(planName, dailyLimit, monthlyLimit, dailyUsed, monthlyUsed, nowUtc);
+ _cache.Set(cacheKey, decision, CacheDuration);
+ return decision;
+ }
+
+ ///
+ /// Plan resolution only (no counter read) — used by the self-service usage endpoint.
+ ///
+ public virtual async Task<(string PlanName, int DailyLimit, int MonthlyLimit)> ResolvePlanAsync(string? upn, string? tenantId)
+ {
+ // 1. Per-user override wins when set.
+ string? overridePlan = null;
+ if (!string.IsNullOrWhiteSpace(upn))
+ {
+ try
+ {
+ var mcpUser = await _mcpUserService.GetMcpUserAsync(upn.ToLowerInvariant());
+ overridePlan = string.IsNullOrWhiteSpace(mcpUser?.UsagePlan) ? null : mcpUser!.UsagePlan!.Trim().ToLowerInvariant();
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "[McpQuota] McpUser lookup failed for {Upn} — falling back to tenant edition plan", upn);
+ }
+ }
+
+ // 2. Tenant edition default (fail-closed → Community inside the entitlement service).
+ var entitlements = await _entitlementService.GetEntitlementsAsync(tenantId);
+ var planName = overridePlan ?? entitlements.McpUsagePlanName;
+
+ // 3. Limits: admin-edited SectionUsagePlans definition for that name, else catalog
+ // fallback for the edition plans, else Community fallback (fail-closed for
+ // overrides naming a plan that exists nowhere).
+ try
+ {
+ var adminConfig = await _adminConfigService.GetConfigurationAsync();
+ var definition = PlanTierDefinitionParser.Parse(adminConfig.PlanTierDefinitionsJson)
+ .FirstOrDefault(t => string.Equals(t.Name, planName, StringComparison.OrdinalIgnoreCase));
+ if (definition != null)
+ return (planName, definition.DailyRequestLimit, definition.MonthlyRequestLimit);
+ }
+ catch (Exception ex)
+ {
+ _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)
+ : FeatureEntitlementCatalog.Get(TenantEdition.Community);
+ return (planName, fallback.McpDailyRequestLimit, fallback.McpMonthlyRequestLimit);
+ }
+
+ internal static McpQuotaDecision BuildDecision(
+ string planName, int dailyLimit, int monthlyLimit, long dailyUsed, long monthlyUsed, DateTime nowUtc)
+ {
+ // Daily window resets at midnight UTC; monthly on the 1st. 0/negative limit = unlimited
+ // for that scope (an operator can deliberately lift a scope via SectionUsagePlans).
+ var dailyExceeded = dailyLimit > 0 && dailyUsed >= dailyLimit;
+ var monthlyExceeded = monthlyLimit > 0 && monthlyUsed >= monthlyLimit;
+
+ string? exceededScope = monthlyExceeded ? "monthly" : dailyExceeded ? "daily" : null;
+ var resetUtc = exceededScope == "monthly"
+ ? new DateTime(nowUtc.Year, nowUtc.Month, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(1)
+ : nowUtc.Date.AddDays(1);
+
+ return new McpQuotaDecision
+ {
+ Allowed = exceededScope == null,
+ Plan = planName,
+ Scope = exceededScope,
+ DailyLimit = dailyLimit,
+ MonthlyLimit = monthlyLimit,
+ DailyUsed = dailyUsed,
+ MonthlyUsed = monthlyUsed,
+ ResetUtc = resetUtc
+ };
+ }
+ }
+
+ /// Outcome of an MCP quota check.
+ public sealed class McpQuotaDecision
+ {
+ public bool Allowed { get; init; }
+ public string Plan { get; init; } = string.Empty;
+ /// Which window was exceeded ("daily"/"monthly"), null when allowed.
+ public string? Scope { get; init; }
+ public int DailyLimit { get; init; }
+ public int MonthlyLimit { get; init; }
+ public long DailyUsed { get; init; }
+ public long MonthlyUsed { get; init; }
+ /// When the exceeded (or daily, when allowed) window resets.
+ public DateTime ResetUtc { get; init; }
+
+ public static McpQuotaDecision FailOpen(string plan, int dailyLimit, int monthlyLimit) => new()
+ {
+ Allowed = true,
+ Plan = plan,
+ DailyLimit = dailyLimit,
+ MonthlyLimit = monthlyLimit,
+ DailyUsed = -1,
+ MonthlyUsed = -1,
+ ResetUtc = DateTime.MinValue
+ };
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/McpUserService.cs b/src/Backend/AutopilotMonitor.Functions/Services/McpUserService.cs
index df09f3c9..ed0b3939 100644
--- a/src/Backend/AutopilotMonitor.Functions/Services/McpUserService.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Services/McpUserService.cs
@@ -49,8 +49,11 @@ public class McpUserService
///
/// Checks if a user is allowed to access the MCP server based on current policy.
+ /// is the caller's JWT tid (home tenant) — it gates the
+ /// delegated (MSP) auto-grant path, which requires an Enterprise home tenant. Null (unknown)
+ /// fails closed to an empty delegated scope; the other grant paths are unaffected.
///
- public virtual async Task IsAllowedAsync(string? upn)
+ public virtual async Task IsAllowedAsync(string? upn, string? homeTenantId = null)
{
if (string.IsNullOrWhiteSpace(upn))
return McpAccessCheckResult.Denied("Missing UPN");
@@ -70,7 +73,7 @@ public class McpUserService
// a delegated assignment; both are reported. The MCP server uses delegatedTenantIds to route a
// delegated caller to /api/global/*?tenantId= (the path the auth middleware authorizes)
// and to reject any tool call that does not name one of the managed tenants.
- var delegatedScope = await _delegatedAdminService.GetScopeAsync(upn);
+ var delegatedScope = await _delegatedAdminService.GetScopeAsync(upn, homeTenantId);
var delegatedTenantIds = delegatedScope.IsEmpty ? null : delegatedScope.TenantIds;
var delegatedRole = delegatedScope.IsEmpty ? null : StrongestDelegatedRole(delegatedScope);
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/OpsEventService.cs b/src/Backend/AutopilotMonitor.Functions/Services/OpsEventService.cs
index 032a1fbd..54fae30f 100644
--- a/src/Backend/AutopilotMonitor.Functions/Services/OpsEventService.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Services/OpsEventService.cs
@@ -430,6 +430,32 @@ namespace AutopilotMonitor.Functions.Services
new { domainName, failedPhase, errorMessage, retryCount });
}
+ // ── Tenant trial lifecycle (informational — enforcement is read-time) ──
+ // Both types are dual-registered in OpsAlertRulesSection.tsx OPS_EVENT_TYPES
+ // (memory feedback_ops_event_types_dual_register). Dispatched by TrialExpirySweepFunction.
+
+ /// Heads-up: an Enterprise trial ends within the next few days. Info-tier visibility signal.
+ 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)",
+ tenantId, "System.TrialSweep", new { domainName, trialExpiresUtc, daysLeft });
+ }
+
+ ///
+ /// An Enterprise 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.
+ ///
+ public Task RecordTenantTrialExpiredAsync(string tenantId, string? domainName, DateTime trialExpiredUtc)
+ {
+ 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",
+ tenantId, "System.TrialSweep", new { domainName, trialExpiredUtc });
+ }
+
// ── Agent ──────────────────────────────────────────────────────────────
public Task RecordSessionTimeoutsAsync(string tenantId, int sessionCount, int timeoutHours)
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/RateLimitResolver.cs b/src/Backend/AutopilotMonitor.Functions/Services/RateLimitResolver.cs
index b083a0f6..3720c8ec 100644
--- a/src/Backend/AutopilotMonitor.Functions/Services/RateLimitResolver.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Services/RateLimitResolver.cs
@@ -6,23 +6,32 @@ namespace AutopilotMonitor.Functions.Services
/// the effective value is computed at read time — there is no denormalized per-tenant mirror and
/// no background sync job. Both the device path ( ) and the user path
/// (UserRateLimitMiddleware ) resolve through here so the precedence stays consistent.
+ ///
+ /// Edition entitlement floor (FeatureEntitlementCatalog ): Enterprise 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).
///
public static class RateLimitResolver
{
///
- /// Device (agent/cert) limit: per-tenant override if set, otherwise the global default.
+ /// Device (agent/cert) limit: per-tenant override if set, otherwise the global default
+ /// raised to the edition's entitlement floor (null floor = Community, default unchanged).
///
- public static int ResolveDeviceLimit(int? tenantOverride, int globalDefault)
- => tenantOverride ?? globalDefault;
+ public static int ResolveDeviceLimit(int? tenantOverride, int globalDefault, int? entitlementFloor = null)
+ => tenantOverride ?? ApplyFloor(globalDefault, entitlementFloor);
///
/// User (portal/JWT) limit. Global Admins are limited by the global GA budget (cross-tenant;
- /// no per-tenant override applies). Standard users get the per-tenant override if set,
- /// otherwise the global user default.
+ /// no per-tenant override and no entitlement floor applies). Standard users get the per-tenant
+ /// override if set, otherwise the global user default raised to the edition's entitlement floor.
///
- public static int ResolveUserLimit(bool isGlobalAdmin, int? tenantUserOverride, int globalUserDefault, int globalAdminDefault)
+ public static int ResolveUserLimit(bool isGlobalAdmin, int? tenantUserOverride, int globalUserDefault, int globalAdminDefault, int? entitlementFloor = null)
=> isGlobalAdmin
? globalAdminDefault
- : (tenantUserOverride ?? globalUserDefault);
+ : (tenantUserOverride ?? ApplyFloor(globalUserDefault, entitlementFloor));
+
+ private static int ApplyFloor(int globalDefault, int? entitlementFloor)
+ => entitlementFloor is int floor && floor > globalDefault ? floor : globalDefault;
}
}
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TenantEntitlementService.cs b/src/Backend/AutopilotMonitor.Functions/Services/TenantEntitlementService.cs
new file mode 100644
index 00000000..93721939
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions/Services/TenantEntitlementService.cs
@@ -0,0 +1,92 @@
+using System;
+using System.Threading.Tasks;
+using AutopilotMonitor.Functions.Security;
+using AutopilotMonitor.Shared.Models;
+using Microsoft.Extensions.Logging;
+
+namespace AutopilotMonitor.Functions.Services
+{
+ ///
+ /// Resolves a tenant's effective edition (Community/Enterprise) and its entitlements at read
+ /// time. Rides on '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.
+ ///
+ public class TenantEntitlementService
+ {
+ private readonly TenantConfigurationService _configService;
+ private readonly ILogger _logger;
+ private readonly TimeProvider _time;
+
+ public TenantEntitlementService(
+ TenantConfigurationService configService,
+ ILogger logger)
+ : this(configService, logger, TimeProvider.System)
+ {
+ }
+
+ /// Test seam — inject a fake for deterministic trial math.
+ public TenantEntitlementService(
+ TenantConfigurationService configService,
+ ILogger logger,
+ TimeProvider time)
+ {
+ _configService = configService;
+ _logger = logger;
+ _time = time;
+ }
+
+ ///
+ /// Resolves the tenant's effective edition. Uses the strict point-read
+ /// ( ) so an
+ /// entitlement check can never materialize a tenant-config row. No row / any error →
+ /// Community (fail-closed).
+ ///
+ public virtual async Task GetEditionAsync(string? tenantId)
+ {
+ if (string.IsNullOrWhiteSpace(tenantId))
+ return TenantEdition.Community;
+
+ try
+ {
+ var config = await _configService.GetConfigurationIfExistsAsync(tenantId);
+ if (config == null)
+ return TenantEdition.Community;
+
+ return ResolveEdition(config, _time.GetUtcNow().UtcDateTime);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex,
+ "[Entitlement] Edition resolution failed for tenant {TenantId} — treating as Community (fail-closed)",
+ tenantId);
+ return TenantEdition.Community;
+ }
+ }
+
+ /// Resolves the tenant's effective entitlement set (fail-closed → Community values).
+ public virtual async Task GetEntitlementsAsync(string? tenantId)
+ => FeatureEntitlementCatalog.Get(await GetEditionAsync(tenantId));
+
+ /// Pure edition resolution for callers that already hold the config.
+ public static TenantEdition ResolveEdition(TenantConfiguration config, DateTime nowUtc)
+ => FeatureEntitlementCatalog.ResolveEdition(config.PlanTier, config.TrialExpiresUtc, nowUtc);
+
+ ///
+ /// The retention days the platform actually enforces for this config: the stored value
+ /// clamped to the edition's cap. days <= 0 is the GA-only "infinite" escape hatch
+ /// and is passed through unclamped (the fanout skips those tenants entirely).
+ ///
+ public static int GetEffectiveRetentionDays(TenantConfiguration config, DateTime nowUtc)
+ {
+ var days = config.DataRetentionDays;
+ if (days <= 0)
+ return 0;
+
+ var cap = FeatureEntitlementCatalog.Get(ResolveEdition(config, nowUtc)).RetentionCapDays;
+ return Math.Min(days, cap);
+ }
+ }
+}
diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts
index d4feeb5e..c9b56d7b 100644
--- a/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts
+++ b/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts
@@ -14,7 +14,8 @@ import { toolError } from './error-handler.js';
* is dropped by default rather than leaking by omission.
*/
export const TENANT_SAFE_FIELDS: ReadonlySet = new Set([
- 'tenantId', 'domainName', 'planTier', 'disabled', 'disabledReason',
+ 'tenantId', 'domainName', 'planTier', 'trialExpiresUtc', 'trialConsumed',
+ 'disabled', 'disabledReason',
'onboardedAt', 'onboardedBy', 'lastUpdated', 'dataRetentionDays',
]);
diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Config/PlanTierDefinitionParser.cs b/src/Shared/AutopilotMonitor.Shared/Models/Config/PlanTierDefinitionParser.cs
new file mode 100644
index 00000000..49fcdfdb
--- /dev/null
+++ b/src/Shared/AutopilotMonitor.Shared/Models/Config/PlanTierDefinitionParser.cs
@@ -0,0 +1,31 @@
+using System.Collections.Generic;
+using System.Text.Json;
+
+namespace AutopilotMonitor.Shared.Models.Config
+{
+ ///
+ /// Parses into typed definitions.
+ /// Shared between the plan management surface and MCP quota enforcement so both read the
+ /// exact same shape. Malformed/empty JSON → empty list (callers fall back to code defaults).
+ ///
+ public static class PlanTierDefinitionParser
+ {
+ private static readonly JsonSerializerOptions Options = new() { PropertyNameCaseInsensitive = true };
+
+ public static List Parse(string? json)
+ {
+ if (string.IsNullOrWhiteSpace(json))
+ return new List();
+
+ try
+ {
+ return JsonSerializer.Deserialize>(json, Options)
+ ?? new List();
+ }
+ catch
+ {
+ return new List();
+ }
+ }
+ }
+}
diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs b/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs
index 4d02811f..cf0722db 100644
--- a/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs
+++ b/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs
@@ -93,11 +93,36 @@ namespace AutopilotMonitor.Shared.Models
///
/// Tenant plan tier. Determines default API rate limits and feature gates.
- /// Values: "free", "pro", "enterprise". Default: "free".
+ /// Write-side values: "community", "enterprise". Legacy stored values ("free", "pro")
+ /// remain readable and resolve to Community (fail-closed — see FeatureEntitlementCatalog).
/// Managed by Global Admins.
///
public string PlanTier { get; set; } = "free";
+ ///
+ /// End of the tenant's Enterprise trial (UTC). While this is in the future the tenant's
+ /// effective edition is Enterprise regardless of . Null = no trial.
+ /// Expiry degrades the tenant to Community at read time — no timer involved.
+ ///
+ public DateTime? TrialExpiresUtc { get; set; }
+
+ ///
+ /// When the tenant's Enterprise trial was started (UTC). Informational/audit only.
+ ///
+ public DateTime? TrialStartedUtc { get; set; }
+
+ ///
+ /// Whether the tenant has used its one self-service trial. Once true, further trials can
+ /// only be granted by a Global Admin via the plan management endpoint (which does not
+ /// reset this flag).
+ ///
+ public bool TrialConsumed { get; set; }
+
+ ///
+ /// Who granted/started the trial (UPN of the self-service caller or the Global Admin).
+ ///
+ public string? TrialGrantedBy { get; set; }
+
///
/// Hardware whitelist: Allowed manufacturers (supports wildcards like "Dell*")
/// Comma-separated list
diff --git a/src/Web/autopilot-monitor-web/app/admin/components/OpsAlertRulesSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/OpsAlertRulesSection.tsx
index 234b5891..921415f7 100644
--- a/src/Web/autopilot-monitor-web/app/admin/components/OpsAlertRulesSection.tsx
+++ b/src/Web/autopilot-monitor-web/app/admin/components/OpsAlertRulesSection.tsx
@@ -77,7 +77,17 @@ const OPS_EVENT_TYPES: Record = {
// OffboardingFeedbackReceived fires when a departing admin submits free-form feedback
// in the drain-barrier banner. Information-tier — wire Telegram to get pinged when
// someone leaves a comment so you can read it promptly.
- Tenant: ["TenantOffboarded", "TenantOffboardingFailed", "OffboardingFeedbackReceived"],
+ // TenantTrialExpiring/Expired — dual-register per memory feedback_ops_event_types_dual_register.
+ // Dispatched by TrialExpirySweepFunction (daily 03:30 UTC, informational — enforcement is
+ // read-time). Expired is Warning (conversion moment: tenant degraded to Community); Expiring
+ // is Info (≤3-day heads-up, re-emitted daily until expiry).
+ Tenant: [
+ "TenantOffboarded",
+ "TenantOffboardingFailed",
+ "OffboardingFeedbackReceived",
+ "TenantTrialExpiring",
+ "TenantTrialExpired",
+ ],
Agent: ["BlobStorageMissing", "BlobStorageUnreachable", "NewImeVersionDetected", "ExcessiveSessionEvents"],
};
diff --git a/src/Web/autopilot-monitor-web/app/admin/components/TenantManagementSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/TenantManagementSection.tsx
index 8d42e204..f610736b 100644
--- a/src/Web/autopilot-monitor-web/app/admin/components/TenantManagementSection.tsx
+++ b/src/Web/autopilot-monitor-web/app/admin/components/TenantManagementSection.tsx
@@ -28,6 +28,10 @@ export interface TenantConfiguration {
dataRetentionDays: number;
sessionTimeoutHours: number;
planTier?: string;
+ /** Enterprise-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;
}
export interface TenantManagementSectionProps {
@@ -61,6 +65,7 @@ export function TenantManagementSection({
const [tenantSectionExpanded, setTenantSectionExpanded] = useState(false);
const [editingTenant, setEditingTenant] = useState(null);
const [savingTenant, setSavingTenant] = useState(false);
+ const [savingPlan, setSavingPlan] = useState(false);
const [currentPage, setCurrentPage] = useState(0);
const tenantsPerPage = tenantSectionExpanded ? 7 : 3;
@@ -133,6 +138,52 @@ export function TenantManagementSection({
}
};
+ // Plan & trial have their OWN save path (PATCH /config/{id}/plan) — the generic PUT above
+ // preserves these fields server-side, so they can only be mutated here.
+ const handleSavePlan = async (tenant: TenantConfiguration) => {
+ if (!canMutate) return; // read-only Global Reader
+ try {
+ setSavingPlan(true);
+ setError(null);
+ setSuccessMessage(null);
+
+ const response = await authenticatedFetch(api.config.plan(tenant.tenantId), getAccessToken, {
+ 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",
+ trialExpiresUtc: tenant.trialExpiresUtc ?? null,
+ }),
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}));
+ throw new Error(errorData.error || `Failed to save plan: ${response.statusText}`);
+ }
+
+ const result = await response.json();
+ const apply = (t: TenantConfiguration): TenantConfiguration => ({
+ ...t,
+ planTier: result.planTier,
+ trialExpiresUtc: result.trialExpiresUtc ?? null,
+ trialConsumed: result.trialConsumed ?? t.trialConsumed,
+ });
+ setTenants(prev => prev.map(t => (t.tenantId === tenant.tenantId ? apply(t) : t)));
+ setEditingTenant(prev => (prev && prev.tenantId === tenant.tenantId ? apply(prev) : prev));
+ setSuccessMessage(`Plan saved — effective edition: ${result.effectiveEdition}`);
+ setTimeout(() => setSuccessMessage(null), 4000);
+ } catch (err) {
+ if (err instanceof TokenExpiredError) {
+ console.error("Session expired while saving plan");
+ }
+ setError(err instanceof Error ? err.message : "Failed to save plan");
+ } finally {
+ setSavingPlan(false);
+ }
+ };
+
const handleSendWelcomeEmail = async (tenantId: string, email?: string) => {
if (!canMutate) return; // read-only Global Reader
try {
@@ -502,6 +553,94 @@ export function TenantManagementSection({
+ {/* Plan & Trial (own save path — PATCH plan endpoint; the modal's generic Save
+ does not touch these fields, the backend preserves them on PUT) */}
+
+
+
Plan & Trial
+ {(() => {
+ const isEnterpriseTier = editingTenant.planTier === "enterprise";
+ const trialActive = !!editingTenant.trialExpiresUtc &&
+ new Date(editingTenant.trialExpiresUtc).getTime() > Date.now();
+ const effective = isEnterpriseTier || trialActive ? "Enterprise" : "Community";
+ return (
+
+ Effective: {effective}{!isEnterpriseTier && trialActive ? " (Trial)" : ""}
+
+ );
+ })()}
+
+
+
+
Plan Tier
+
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"
+ >
+ Community
+ Enterprise
+
+ {editingTenant.planTier && editingTenant.planTier !== "enterprise" && editingTenant.planTier !== "community" && (
+
+ Stored legacy tier "{editingTenant.planTier}" resolves to Community. Saving normalizes it.
+
+ )}
+
+
+
Trial Ends (UTC)
+
+ setEditingTenant({
+ ...editingTenant,
+ trialExpiresUtc: e.target.value ? new Date(e.target.value + "Z").toISOString() : null,
+ })}
+ className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
+ />
+ {editingTenant.trialExpiresUtc && (
+ setEditingTenant({ ...editingTenant, trialExpiresUtc: null })}
+ className="px-3 py-2 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
+ title="End the trial (saves as no trial)"
+ >
+ Clear
+
+ )}
+
+
+ Set a date to grant/extend an Enterprise trial; clear to end it. Saving does not reset trial consumption.
+
+
+
+
+ {editingTenant.trialConsumed
+ ? "Self-service trial: already consumed (re-grants only via this panel)."
+ : "Self-service trial: still available to the tenant."}
+
+
handleSavePlan(editingTenant)}
+ disabled={!canMutate || savingPlan}
+ className="px-3 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center gap-1.5"
+ >
+ {savingPlan ? (
+ <>
+
+ Saving…
+ >
+ ) : (
+ Save Plan
+ )}
+
+
+
+
+
{/* Admin Users Info */}
{editingTenant.dataRetentionDays === 0 ? (
⚠ Infinite retention — data will never be automatically deleted
- ) : (editingTenant.dataRetentionDays < 7 || editingTenant.dataRetentionDays > 180) ? (
- ⚠ Outside tenant range (7–180) — field will be locked for tenant admins
+ ) : (editingTenant.dataRetentionDays < 7 || editingTenant.dataRetentionDays > 365) ? (
+ ⚠ Outside tenant range (7–365) — field will be locked for tenant admins
) : (
- Tenant range: 7–180. Set 0 for infinite retention. No upper limit for Global.
+ 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).
)}
diff --git a/src/Web/autopilot-monitor-web/app/admin/tenants/sections/SectionTenantConfigReport.tsx b/src/Web/autopilot-monitor-web/app/admin/tenants/sections/SectionTenantConfigReport.tsx
index d5871b34..e8c85440 100644
--- a/src/Web/autopilot-monitor-web/app/admin/tenants/sections/SectionTenantConfigReport.tsx
+++ b/src/Web/autopilot-monitor-web/app/admin/tenants/sections/SectionTenantConfigReport.tsx
@@ -74,6 +74,8 @@ interface TenantConfig {
// Plan tier
planTier?: string;
+ trialExpiresUtc?: string | null;
+ trialConsumed?: boolean;
// Bootstrap & unrestricted
bootstrapTokenEnabled?: boolean;
@@ -515,7 +517,12 @@ export function SectionTenantConfigReport() {
-
+ Date.now() ? ` (trial until ${formatDate(config.trialExpiresUtc)})` : ''}`}
+ configKey="planTier"
+ defaults={DEFAULTS}
+ />
diff --git a/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx b/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx
index 4483347c..93dfb895 100644
--- a/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx
+++ b/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx
@@ -25,6 +25,7 @@ const validationEnabledSuffix = (t: ValidationTrigger) =>
? " enabled (shadow mode — does not block enrollment)."
: " enabled. Backend agent endpoints are now unlocked for this tenant.";
import { parseSasExpiry } from "./components/DiagnosticsSection";
+import { COMMUNITY_DEFAULT, parseEditionInfo, type EditionInfo } from "@/lib/edition";
import { TenantConfiguration, TenantAdmin, DiagnosticsLogPath } from "./types";
import { type BootstrapSessionItem } from "./components/BootstrapSessionsSection";
@@ -53,6 +54,12 @@ interface TenantConfigContextValue {
successMessage: string | null;
setSuccessMessage: (m: string | null) => void;
+ // 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. */
+ startTrial: () => Promise;
+
// Validation
validateAutopilotDevice: boolean;
setValidateAutopilotDevice: (v: boolean) => void;
@@ -375,6 +382,10 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
// Unrestricted mode
const [unrestrictedMode, setUnrestrictedMode] = useState(false);
+ // Edition / trial surface (from feature-flags; fail-closed Community until loaded)
+ const [editionInfo, setEditionInfo] = useState(COMMUNITY_DEFAULT);
+ const [startingTrial, setStartingTrial] = useState(false);
+
// -----------------------------------------------------------------------
// Fetch configuration
// -----------------------------------------------------------------------
@@ -398,10 +409,22 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
const flags = await response.json();
// Create a minimal config object with just the feature flag
setConfig({ bootstrapTokenEnabled: flags.bootstrapTokenEnabled } as TenantConfiguration);
+ setEditionInfo(parseEditionInfo(flags));
return;
}
- const response = await authenticatedFetch(api.config.tenant(tenantId), getAccessToken);
+ // Admin path: full config + feature flags in parallel — the edition surface is
+ // resolved SERVER-side (trial expiry math), so it comes from flags, not the raw config.
+ const [response, flagsResponse] = await Promise.all([
+ authenticatedFetch(api.config.tenant(tenantId), getAccessToken),
+ authenticatedFetch(api.config.featureFlags(tenantId), getAccessToken),
+ ]);
+
+ if (flagsResponse.ok) {
+ try {
+ setEditionInfo(parseEditionInfo(await flagsResponse.json()));
+ } catch { /* fail-closed: keep Community default */ }
+ }
if (!response.ok) {
throw new Error(`Failed to load configuration: ${response.statusText}`);
@@ -1408,6 +1431,45 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
logout();
}, [logout]);
+ // -----------------------------------------------------------------------
+ // Self-service Enterprise trial (once per tenant — backend enforces via 409)
+ // -----------------------------------------------------------------------
+ const startTrial = useCallback(async (): Promise => {
+ if (!tenantId) return false;
+ try {
+ setStartingTrial(true);
+ setError(null);
+
+ const response = await authenticatedFetch(api.config.trial(tenantId), getAccessToken, {
+ method: "POST",
+ });
+
+ if (!response.ok) {
+ const data = await response.json().catch(() => ({}));
+ throw new Error(data.message || data.error || `Failed to start trial: ${response.statusText}`);
+ }
+
+ // Refetch the authoritative edition surface (server-resolved).
+ const flagsResponse = await authenticatedFetch(api.config.featureFlags(tenantId), getAccessToken);
+ if (flagsResponse.ok) {
+ setEditionInfo(parseEditionInfo(await flagsResponse.json()));
+ }
+ setSuccessMessage("Enterprise trial started — all Enterprise features are now active for 30 days.");
+ setTimeout(() => setSuccessMessage(null), 5000);
+ trackEvent("EnterpriseTrialStarted", { tenantId });
+ return true;
+ } catch (err) {
+ if (err instanceof TokenExpiredError) {
+ addNotification('error', 'Session Expired', err.message, 'session-expired-error');
+ } else {
+ setError(err instanceof Error ? err.message : "Failed to start trial");
+ }
+ return false;
+ } finally {
+ setStartingTrial(false);
+ }
+ }, [tenantId, getAccessToken, addNotification]);
+
// -----------------------------------------------------------------------
// Provider value
// -----------------------------------------------------------------------
@@ -1416,6 +1478,9 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
config, loading, savingSection,
error, setError, successMessage, setSuccessMessage,
+ // Edition / trial
+ editionInfo, startingTrial, startTrial,
+
// Validation
validateAutopilotDevice, setValidateAutopilotDevice,
validateCorporateIdentifier, setValidateCorporateIdentifier,
diff --git a/src/Web/autopilot-monitor-web/app/settings/components/DataManagementSection.tsx b/src/Web/autopilot-monitor-web/app/settings/components/DataManagementSection.tsx
index 7d4d1e82..71921d40 100644
--- a/src/Web/autopilot-monitor-web/app/settings/components/DataManagementSection.tsx
+++ b/src/Web/autopilot-monitor-web/app/settings/components/DataManagementSection.tsx
@@ -8,6 +8,8 @@ interface DataManagementSectionProps {
sessionTimeoutHours: number;
setSessionTimeoutHours: (value: number) => void;
isGlobalAdmin?: boolean;
+ /** Edition retention cap (Community 90 / Enterprise 365) — from feature-flags entitlements. */
+ retentionCapDays?: number;
onSave: () => Promise | void;
onReset: () => void;
saving: boolean;
@@ -19,11 +21,12 @@ export default function DataManagementSection({
sessionTimeoutHours,
setSessionTimeoutHours,
isGlobalAdmin,
+ retentionCapDays = 90,
onSave,
onReset,
saving,
}: DataManagementSectionProps) {
- const isOverridden = dataRetentionDays === 0 || dataRetentionDays < 7 || dataRetentionDays > 180;
+ const isOverridden = dataRetentionDays === 0 || dataRetentionDays < 7 || dataRetentionDays > retentionCapDays;
const isRetentionDisabled = isOverridden && !isGlobalAdmin;
return (
@@ -50,7 +53,7 @@ export default function DataManagementSection({
setDataRetentionDays(parseInt(e.target.value) || 90)}
@@ -67,12 +70,14 @@ export default function DataManagementSection({
Overridden by administrator —{" "}
{dataRetentionDays === 0
? "Infinite retention is active. Data will never be automatically deleted."
- : `Retention is set to ${dataRetentionDays} days (outside the standard 7–180 day range).`}
+ : `Retention is set to ${dataRetentionDays} days (outside the standard 7–${retentionCapDays} day range for your plan).`}
{" "}Contact your Global admin to change this value.
) : (
- Minimum: 7 days, Maximum: 180 days
+
+ Minimum: 7 days, Maximum: {retentionCapDays} days{retentionCapDays < 365 ? " (up to 365 with Enterprise)" : ""}
+
)}
diff --git a/src/Web/autopilot-monitor-web/app/settings/components/EditionBadge.tsx b/src/Web/autopilot-monitor-web/app/settings/components/EditionBadge.tsx
new file mode 100644
index 00000000..41ee4062
--- /dev/null
+++ b/src/Web/autopilot-monitor-web/app/settings/components/EditionBadge.tsx
@@ -0,0 +1,75 @@
+"use client";
+
+import { useState } from "react";
+import { editionLabel } from "@/lib/edition";
+import { useTenantConfig } from "../TenantConfigContext";
+
+/**
+ * Edition badge + self-service trial CTA for the settings header.
+ * - "Enterprise" / "Enterprise Trial — X days left" / "Community"
+ * - "Start 30-day Enterprise trial" button only when the backend reports the trial as
+ * still available (never consumed + currently Community) AND the caller is a tenant admin.
+ * One-time action → guarded by an inline confirm step.
+ */
+export default function EditionBadge() {
+ const { editionInfo, startTrial, startingTrial, user } = useTenantConfig();
+ const [confirming, setConfirming] = useState(false);
+
+ const isEnterprise = editionInfo.edition === "enterprise";
+ const label = editionLabel(editionInfo);
+ const canStartTrial =
+ editionInfo.trialAvailable && (user?.isTenantAdmin === true || user?.isGlobalAdmin === true);
+
+ return (
+
+
+ {label}
+
+
+ {canStartTrial && !confirming && (
+ setConfirming(true)}
+ className="text-xs font-medium text-purple-700 border border-purple-300 rounded-full px-3 py-1 hover:bg-purple-50 transition-colors"
+ >
+ Start 30-day Enterprise trial
+
+ )}
+
+ {canStartTrial && confirming && (
+
+ One-time trial — start now?
+ {
+ const ok = await startTrial();
+ if (ok) setConfirming(false);
+ }}
+ disabled={startingTrial}
+ className="font-medium text-white bg-purple-600 rounded-full px-3 py-1 hover:bg-purple-700 disabled:opacity-50 transition-colors"
+ >
+ {startingTrial ? "Starting…" : "Confirm"}
+
+ setConfirming(false)}
+ disabled={startingTrial}
+ className="text-gray-500 hover:text-gray-700"
+ >
+ Cancel
+
+
+ )}
+
+ );
+}
diff --git a/src/Web/autopilot-monitor-web/app/settings/management/sections/SectionDataManagement.tsx b/src/Web/autopilot-monitor-web/app/settings/management/sections/SectionDataManagement.tsx
index fefa8d58..6a946a15 100644
--- a/src/Web/autopilot-monitor-web/app/settings/management/sections/SectionDataManagement.tsx
+++ b/src/Web/autopilot-monitor-web/app/settings/management/sections/SectionDataManagement.tsx
@@ -8,7 +8,7 @@ export function SectionDataManagement() {
const {
dataRetentionDays, setDataRetentionDays,
sessionTimeoutHours, setSessionTimeoutHours,
- user,
+ user, editionInfo,
handleSaveDataManagement, handleResetDataManagement,
savingSection,
} = useTenantConfig();
@@ -22,6 +22,7 @@ export function SectionDataManagement() {
sessionTimeoutHours={sessionTimeoutHours}
setSessionTimeoutHours={setSessionTimeoutHours}
isGlobalAdmin={user?.isGlobalAdmin}
+ retentionCapDays={editionInfo.entitlements.retentionCapDays}
onSave={handleSaveDataManagement}
onReset={handleResetDataManagement}
saving={savingSection === "dataManagement"}
diff --git a/src/Web/autopilot-monitor-web/app/settings/tenant/TenantSidebar.tsx b/src/Web/autopilot-monitor-web/app/settings/tenant/TenantSidebar.tsx
index d38c0120..8088ae6f 100644
--- a/src/Web/autopilot-monitor-web/app/settings/tenant/TenantSidebar.tsx
+++ b/src/Web/autopilot-monitor-web/app/settings/tenant/TenantSidebar.tsx
@@ -1,12 +1,17 @@
"use client";
+import EditionBadge from "../components/EditionBadge";
+
export function TenantSidebar({ children }: { children: React.ReactNode }) {
return (
<>
-
-
Tenant Configuration
-
Validation, notifications, and access management
+
+
+
Tenant Configuration
+
Validation, notifications, and access management
+
+
diff --git a/src/Web/autopilot-monitor-web/app/settings/types.ts b/src/Web/autopilot-monitor-web/app/settings/types.ts
index e457a37c..e5f81317 100644
--- a/src/Web/autopilot-monitor-web/app/settings/types.ts
+++ b/src/Web/autopilot-monitor-web/app/settings/types.ts
@@ -82,6 +82,10 @@ export interface TenantConfiguration {
// Unrestricted mode
unrestrictedModeEnabled?: boolean;
unrestrictedMode?: boolean;
+ // Plan / edition (read-only here — mutated only via the dedicated plan/trial endpoints)
+ planTier?: string;
+ trialExpiresUtc?: string | null;
+ trialConsumed?: boolean;
}
export interface TenantAdmin {
diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/edition.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/edition.test.ts
new file mode 100644
index 00000000..8f19be37
--- /dev/null
+++ b/src/Web/autopilot-monitor-web/lib/__tests__/edition.test.ts
@@ -0,0 +1,97 @@
+import { describe, expect, it } from "vitest";
+import {
+ COMMUNITY_DEFAULT,
+ editionLabel,
+ parseEditionInfo,
+ trialDaysLeft,
+} from "../edition";
+
+const NOW = new Date("2026-07-07T12:00:00Z");
+
+describe("parseEditionInfo", () => {
+ it("parses a full enterprise-trial payload", () => {
+ const info = parseEditionInfo({
+ edition: "enterprise",
+ isTrial: true,
+ trialExpiresUtc: "2026-07-20T12:00:00Z",
+ trialAvailable: false,
+ entitlements: {
+ retentionCapDays: 365,
+ userRateLimitPerMinute: 150,
+ delegatedAdminAllowed: true,
+ mcpUsagePlan: "enterprise",
+ },
+ });
+
+ expect(info.edition).toBe("enterprise");
+ expect(info.isTrial).toBe(true);
+ expect(info.trialExpiresUtc).toBe("2026-07-20T12:00:00Z");
+ expect(info.entitlements.retentionCapDays).toBe(365);
+ expect(info.entitlements.userRateLimitPerMinute).toBe(150);
+ expect(info.entitlements.delegatedAdminAllowed).toBe(true);
+ });
+
+ it("fails closed to Community for malformed payloads", () => {
+ expect(parseEditionInfo(null)).toEqual(COMMUNITY_DEFAULT);
+ expect(parseEditionInfo(undefined)).toEqual(COMMUNITY_DEFAULT);
+ expect(parseEditionInfo("nope")).toEqual(COMMUNITY_DEFAULT);
+ expect(parseEditionInfo({})).toEqual(COMMUNITY_DEFAULT);
+ });
+
+ it("treats unknown edition strings as community (fail-closed)", () => {
+ const info = parseEditionInfo({ edition: "platinum", trialAvailable: true });
+ expect(info.edition).toBe("community");
+ expect(info.trialAvailable).toBe(true);
+ });
+
+ it("defaults missing entitlements to Community values", () => {
+ const info = parseEditionInfo({ edition: "enterprise" });
+ expect(info.entitlements.retentionCapDays).toBe(90);
+ expect(info.entitlements.userRateLimitPerMinute).toBeNull();
+ });
+});
+
+describe("trialDaysLeft", () => {
+ it("returns 0 for unset/expired/invalid values", () => {
+ expect(trialDaysLeft(null, NOW)).toBe(0);
+ expect(trialDaysLeft(undefined, NOW)).toBe(0);
+ expect(trialDaysLeft("2026-07-07T11:59:59Z", NOW)).toBe(0);
+ expect(trialDaysLeft("not-a-date", NOW)).toBe(0);
+ });
+
+ it("ceils partial days — expiring later today counts as 1", () => {
+ expect(trialDaysLeft("2026-07-07T18:00:00Z", NOW)).toBe(1);
+ });
+
+ it("counts exact whole days", () => {
+ expect(trialDaysLeft("2026-07-10T12:00:00Z", NOW)).toBe(3);
+ expect(trialDaysLeft("2026-08-06T12:00:01Z", NOW)).toBe(31);
+ });
+});
+
+describe("editionLabel", () => {
+ it("labels community", () => {
+ 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 a trial with a day countdown (singular/plural)", () => {
+ const plural = parseEditionInfo({
+ edition: "enterprise",
+ isTrial: true,
+ trialExpiresUtc: "2026-07-10T12:00:00Z",
+ });
+ expect(editionLabel(plural, NOW)).toBe("Enterprise Trial — 3 days left");
+
+ const singular = parseEditionInfo({
+ edition: "enterprise",
+ isTrial: true,
+ trialExpiresUtc: "2026-07-07T18:00:00Z",
+ });
+ expect(editionLabel(singular, NOW)).toBe("Enterprise Trial — 1 day left");
+ });
+});
diff --git a/src/Web/autopilot-monitor-web/lib/api.ts b/src/Web/autopilot-monitor-web/lib/api.ts
index 641e47b2..7692dafe 100644
--- a/src/Web/autopilot-monitor-web/lib/api.ts
+++ b/src/Web/autopilot-monitor-web/lib/api.ts
@@ -101,6 +101,10 @@ export const api = {
all: () => `${API_BASE_URL}/api/config/all`,
tenant: (tenantId: string) => `${API_BASE_URL}/api/config/${tenantId}`,
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). */
+ 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 })}`,
autopilotConsentStatus: (tenantId: string) =>
diff --git a/src/Web/autopilot-monitor-web/lib/edition.ts b/src/Web/autopilot-monitor-web/lib/edition.ts
new file mode 100644
index 00000000..7699214c
--- /dev/null
+++ b/src/Web/autopilot-monitor-web/lib/edition.ts
@@ -0,0 +1,80 @@
+/**
+ * Pure edition/trial math for the Community/Enterprise 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";
+
+/** Edition/trial surface returned by GET /api/config/{tenantId}/feature-flags. */
+export interface EditionInfo {
+ edition: TenantEditionName;
+ isTrial: boolean;
+ trialExpiresUtc: string | null;
+ trialAvailable: boolean;
+ entitlements: {
+ retentionCapDays: number;
+ userRateLimitPerMinute: number | null;
+ delegatedAdminAllowed: boolean;
+ mcpUsagePlan: string;
+ };
+}
+
+/** Fail-closed default while flags are loading or on error: Community, no trial CTA. */
+export const COMMUNITY_DEFAULT: EditionInfo = {
+ edition: "community",
+ isTrial: false,
+ trialExpiresUtc: null,
+ trialAvailable: false,
+ entitlements: {
+ retentionCapDays: 90,
+ userRateLimitPerMinute: null,
+ delegatedAdminAllowed: false,
+ mcpUsagePlan: "community",
+ },
+};
+
+/** Parse the feature-flags payload's edition surface; malformed → Community default. */
+export function parseEditionInfo(flags: unknown): EditionInfo {
+ if (!flags || typeof flags !== "object") return COMMUNITY_DEFAULT;
+ const f = flags as Record;
+ const edition: TenantEditionName = f.edition === "enterprise" ? "enterprise" : "community";
+ const ent = (f.entitlements && typeof f.entitlements === "object"
+ ? (f.entitlements as Record)
+ : {}) as Record;
+ return {
+ edition,
+ isTrial: f.isTrial === true,
+ trialExpiresUtc: typeof f.trialExpiresUtc === "string" ? f.trialExpiresUtc : null,
+ trialAvailable: f.trialAvailable === true,
+ entitlements: {
+ retentionCapDays:
+ typeof ent.retentionCapDays === "number" ? ent.retentionCapDays : COMMUNITY_DEFAULT.entitlements.retentionCapDays,
+ userRateLimitPerMinute:
+ typeof ent.userRateLimitPerMinute === "number" ? ent.userRateLimitPerMinute : null,
+ delegatedAdminAllowed: ent.delegatedAdminAllowed === true,
+ mcpUsagePlan: typeof ent.mcpUsagePlan === "string" ? ent.mcpUsagePlan : "community",
+ },
+ };
+}
+
+/**
+ * Whole days remaining until the trial expires (ceiling — "expires later today" = 1).
+ * Returns 0 when expired or unset.
+ */
+export function trialDaysLeft(trialExpiresUtc: string | null | undefined, now: Date = new Date()): number {
+ if (!trialExpiresUtc) return 0;
+ const expiry = new Date(trialExpiresUtc);
+ if (isNaN(expiry.getTime())) return 0;
+ const ms = expiry.getTime() - now.getTime();
+ return ms <= 0 ? 0 : Math.ceil(ms / 86_400_000);
+}
+
+/** Badge label: "Enterprise", "Enterprise 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";
+ const days = trialDaysLeft(info.trialExpiresUtc, now);
+ return `Enterprise Trial — ${days} day${days === 1 ? "" : "s"} left`;
+}