Skip to content

Commit 77e443c

Browse files
authored
Phase 6 — Implement Code311.Licensing
Proceeding to Phase 7
1 parent cfee4e5 commit 77e443c

10 files changed

Lines changed: 520 additions & 0 deletions

File tree

Code311.sln

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Code311.Persistence.EFCore"
4646
EndProject
4747
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Code311.Tests.Persistence.EFCore", "tests\Code311.Tests.Persistence.EFCore\Code311.Tests.Persistence.EFCore.csproj", "{B2222222-2222-2222-2222-222222222232}"
4848
EndProject
49+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Code311.Licensing", "src\Code311.Licensing\Code311.Licensing.csproj", "{A1111111-1111-1111-1111-111111111122}"
50+
EndProject
51+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Code311.Tests.Licensing", "tests\Code311.Tests.Licensing\Code311.Tests.Licensing.csproj", "{B2222222-2222-2222-2222-222222222233}"
52+
EndProject
4953
Global
5054
GlobalSection(SolutionConfigurationPlatforms) = preSolution
5155
Debug|Any CPU = Debug|Any CPU
@@ -140,6 +144,14 @@ Global
140144
{B2222222-2222-2222-2222-222222222232}.Debug|Any CPU.Build.0 = Debug|Any CPU
141145
{B2222222-2222-2222-2222-222222222232}.Release|Any CPU.ActiveCfg = Release|Any CPU
142146
{B2222222-2222-2222-2222-222222222232}.Release|Any CPU.Build.0 = Release|Any CPU
147+
{A1111111-1111-1111-1111-111111111122}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
148+
{A1111111-1111-1111-1111-111111111122}.Debug|Any CPU.Build.0 = Debug|Any CPU
149+
{A1111111-1111-1111-1111-111111111122}.Release|Any CPU.ActiveCfg = Release|Any CPU
150+
{A1111111-1111-1111-1111-111111111122}.Release|Any CPU.Build.0 = Release|Any CPU
151+
{B2222222-2222-2222-2222-222222222233}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
152+
{B2222222-2222-2222-2222-222222222233}.Debug|Any CPU.Build.0 = Debug|Any CPU
153+
{B2222222-2222-2222-2222-222222222233}.Release|Any CPU.ActiveCfg = Release|Any CPU
154+
{B2222222-2222-2222-2222-222222222233}.Release|Any CPU.Build.0 = Release|Any CPU
143155
EndGlobalSection
144156
GlobalSection(SolutionProperties) = preSolution
145157
HideSolutionNode = FALSE
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
<PropertyGroup>
3+
<TargetFramework>net10.0</TargetFramework>
4+
<RootNamespace>Code311.Licensing</RootNamespace>
5+
<AssemblyName>Code311.Licensing</AssemblyName>
6+
<Description>Hybrid licensing runtime for startup validation and bounded feature checks.</Description>
7+
<PackageId>Code311.Licensing</PackageId>
8+
</PropertyGroup>
9+
10+
<ItemGroup>
11+
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
12+
</ItemGroup>
13+
</Project>
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
using Code311.Licensing.Diagnostics;
2+
using Code311.Licensing.Models;
3+
using Code311.Licensing.Sources;
4+
using Code311.Licensing.Validation;
5+
using Microsoft.Extensions.DependencyInjection;
6+
using Microsoft.Extensions.DependencyInjection.Extensions;
7+
8+
namespace Code311.Licensing.DependencyInjection;
9+
10+
/// <summary>
11+
/// Provides registration helpers for Code311 licensing services.
12+
/// </summary>
13+
public static class ServiceCollectionExtensions
14+
{
15+
/// <summary>
16+
/// Registers Code311 licensing services and an environment-variable-based source.
17+
/// </summary>
18+
public static IServiceCollection AddCode311Licensing(
19+
this IServiceCollection services,
20+
Action<LicensingOptions>? configure = null,
21+
string environmentVariableName = "CODE311_LICENSE_JSON")
22+
{
23+
ArgumentNullException.ThrowIfNull(services);
24+
25+
var options = new LicensingOptions();
26+
configure?.Invoke(options);
27+
28+
services.TryAddSingleton(options);
29+
services.TryAddSingleton<ILicensingStatusReporter, InMemoryLicensingStatusReporter>();
30+
services.TryAddSingleton<ILicenseValidator, DefaultLicenseValidator>();
31+
services.TryAddSingleton<ILicenseSource>(_ => new EnvironmentVariableLicenseSource(environmentVariableName));
32+
services.TryAddSingleton<IStartupLicenseValidator, StartupLicenseValidator>();
33+
services.TryAddSingleton<ILicenseFeatureGate, LicenseFeatureGate>();
34+
35+
return services;
36+
}
37+
38+
/// <summary>
39+
/// Replaces the registered source with an in-memory payload.
40+
/// </summary>
41+
public static IServiceCollection AddCode311InMemoryLicenseSource(this IServiceCollection services, Code311License? license)
42+
{
43+
ArgumentNullException.ThrowIfNull(services);
44+
45+
services.Replace(ServiceDescriptor.Singleton<ILicenseSource>(_ => new InMemoryLicenseSource(license)));
46+
return services;
47+
}
48+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
using Code311.Licensing.Models;
2+
3+
namespace Code311.Licensing.Diagnostics;
4+
5+
/// <summary>
6+
/// Reports and exposes licensing runtime status.
7+
/// </summary>
8+
public interface ILicensingStatusReporter
9+
{
10+
void Report(LicenseRuntimeStatus status);
11+
LicenseRuntimeStatus? Current { get; }
12+
IReadOnlyList<LicenseRuntimeStatus> GetHistory();
13+
}
14+
15+
public sealed class InMemoryLicensingStatusReporter : ILicensingStatusReporter
16+
{
17+
private readonly List<LicenseRuntimeStatus> _history = [];
18+
19+
public LicenseRuntimeStatus? Current { get; private set; }
20+
21+
public void Report(LicenseRuntimeStatus status)
22+
{
23+
ArgumentNullException.ThrowIfNull(status);
24+
Current = status;
25+
_history.Add(status);
26+
}
27+
28+
public IReadOnlyList<LicenseRuntimeStatus> GetHistory() => _history;
29+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
namespace Code311.Licensing.Models;
2+
3+
/// <summary>
4+
/// Represents a parsed Code311 license payload.
5+
/// </summary>
6+
public sealed record Code311License
7+
{
8+
public required string LicenseId { get; init; }
9+
public required string CustomerName { get; init; }
10+
public string Plan { get; init; } = "standard";
11+
public DateTimeOffset? NotBeforeUtc { get; init; }
12+
public DateTimeOffset? ExpiresUtc { get; init; }
13+
public IReadOnlySet<string> Features { get; init; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
14+
}
15+
16+
/// <summary>
17+
/// Configuration options for licensing behavior.
18+
/// </summary>
19+
public sealed class LicensingOptions
20+
{
21+
/// <summary>
22+
/// Indicates whether startup validation is mandatory.
23+
/// </summary>
24+
public bool RequireValidLicenseAtStartup { get; set; } = true;
25+
26+
/// <summary>
27+
/// Number of days before expiry where validation emits warning status.
28+
/// </summary>
29+
public int ExpiryWarningWindowDays { get; set; } = 14;
30+
}
31+
32+
/// <summary>
33+
/// Indicates licensing status severity at runtime.
34+
/// </summary>
35+
public enum LicenseStatusLevel
36+
{
37+
Valid,
38+
Warning,
39+
Error
40+
}
41+
42+
/// <summary>
43+
/// Describes lifecycle stage where a license status was observed.
44+
/// </summary>
45+
public enum LicenseCheckStage
46+
{
47+
Startup,
48+
RuntimeFeature
49+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
namespace Code311.Licensing.Models;
2+
3+
/// <summary>
4+
/// Represents a machine-readable status item produced by licensing operations.
5+
/// </summary>
6+
public sealed record LicenseStatusItem(LicenseStatusLevel Level, string Code, string Message);
7+
8+
/// <summary>
9+
/// Represents the result of license validation.
10+
/// </summary>
11+
public sealed record LicenseValidationResult(
12+
bool IsValid,
13+
LicenseStatusLevel OverallLevel,
14+
IReadOnlyList<LicenseStatusItem> Items,
15+
Code311License? License);
16+
17+
/// <summary>
18+
/// Represents a bounded runtime feature check result.
19+
/// </summary>
20+
public sealed record LicenseFeatureCheckResult(
21+
bool IsAllowed,
22+
string Feature,
23+
LicenseStatusLevel Level,
24+
string Reason,
25+
Code311License? License);
26+
27+
/// <summary>
28+
/// Represents reported runtime status suitable for host diagnostics surfaces.
29+
/// </summary>
30+
public sealed record LicenseRuntimeStatus(
31+
DateTimeOffset OccurredAtUtc,
32+
LicenseCheckStage Stage,
33+
LicenseStatusLevel Level,
34+
string Code,
35+
string Message);
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
using System.Text.Json;
2+
using Code311.Licensing.Models;
3+
4+
namespace Code311.Licensing.Sources;
5+
6+
/// <summary>
7+
/// Resolves a license payload from a configured source.
8+
/// </summary>
9+
public interface ILicenseSource
10+
{
11+
Task<Code311License?> GetLicenseAsync(CancellationToken cancellationToken = default);
12+
}
13+
14+
/// <summary>
15+
/// In-memory source suitable for tests and deterministic bootstrapping.
16+
/// </summary>
17+
public sealed class InMemoryLicenseSource(Code311License? license) : ILicenseSource
18+
{
19+
public Task<Code311License?> GetLicenseAsync(CancellationToken cancellationToken = default)
20+
=> Task.FromResult(license);
21+
}
22+
23+
/// <summary>
24+
/// Reads a license payload from an environment variable containing JSON.
25+
/// </summary>
26+
public sealed class EnvironmentVariableLicenseSource(string variableName) : ILicenseSource
27+
{
28+
public Task<Code311License?> GetLicenseAsync(CancellationToken cancellationToken = default)
29+
{
30+
ArgumentException.ThrowIfNullOrWhiteSpace(variableName);
31+
32+
var payload = Environment.GetEnvironmentVariable(variableName);
33+
if (string.IsNullOrWhiteSpace(payload))
34+
{
35+
return Task.FromResult<Code311License?>(null);
36+
}
37+
38+
var model = JsonSerializer.Deserialize<Code311License>(payload);
39+
return Task.FromResult(model);
40+
}
41+
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
using Code311.Licensing.Diagnostics;
2+
using Code311.Licensing.Models;
3+
using Code311.Licensing.Sources;
4+
5+
namespace Code311.Licensing.Validation;
6+
7+
/// <summary>
8+
/// Validates license payload semantics.
9+
/// </summary>
10+
public interface ILicenseValidator
11+
{
12+
LicenseValidationResult Validate(Code311License? license, DateTimeOffset nowUtc, LicensingOptions options);
13+
}
14+
15+
/// <summary>
16+
/// Provides explicit startup validation flow.
17+
/// </summary>
18+
public interface IStartupLicenseValidator
19+
{
20+
Task<LicenseValidationResult> ValidateAtStartupAsync(CancellationToken cancellationToken = default);
21+
}
22+
23+
/// <summary>
24+
/// Provides bounded runtime feature-level checks for integration points.
25+
/// </summary>
26+
public interface ILicenseFeatureGate
27+
{
28+
Task<LicenseFeatureCheckResult> CheckFeatureAsync(string feature, CancellationToken cancellationToken = default);
29+
}
30+
31+
public sealed class DefaultLicenseValidator : ILicenseValidator
32+
{
33+
public LicenseValidationResult Validate(Code311License? license, DateTimeOffset nowUtc, LicensingOptions options)
34+
{
35+
var items = new List<LicenseStatusItem>();
36+
37+
if (license is null)
38+
{
39+
items.Add(new LicenseStatusItem(LicenseStatusLevel.Error, "license.missing", "No license payload could be resolved."));
40+
return new LicenseValidationResult(false, LicenseStatusLevel.Error, items, null);
41+
}
42+
43+
if (license.NotBeforeUtc.HasValue && nowUtc < license.NotBeforeUtc.Value)
44+
{
45+
items.Add(new LicenseStatusItem(LicenseStatusLevel.Error, "license.not_before", "License is not active yet."));
46+
}
47+
48+
if (license.ExpiresUtc.HasValue)
49+
{
50+
if (nowUtc >= license.ExpiresUtc.Value)
51+
{
52+
items.Add(new LicenseStatusItem(LicenseStatusLevel.Error, "license.expired", "License has expired."));
53+
}
54+
else if (nowUtc >= license.ExpiresUtc.Value.AddDays(-Math.Abs(options.ExpiryWarningWindowDays)))
55+
{
56+
items.Add(new LicenseStatusItem(LicenseStatusLevel.Warning, "license.expiring_soon", "License is approaching expiry."));
57+
}
58+
}
59+
60+
if (items.All(x => x.Level != LicenseStatusLevel.Error))
61+
{
62+
items.Add(new LicenseStatusItem(LicenseStatusLevel.Valid, "license.valid", "License is valid."));
63+
}
64+
65+
var overall = items.Any(x => x.Level == LicenseStatusLevel.Error)
66+
? LicenseStatusLevel.Error
67+
: items.Any(x => x.Level == LicenseStatusLevel.Warning)
68+
? LicenseStatusLevel.Warning
69+
: LicenseStatusLevel.Valid;
70+
71+
return new LicenseValidationResult(overall != LicenseStatusLevel.Error, overall, items, license);
72+
}
73+
}
74+
75+
public sealed class StartupLicenseValidator(
76+
ILicenseSource source,
77+
ILicenseValidator validator,
78+
ILicensingStatusReporter reporter,
79+
LicensingOptions options) : IStartupLicenseValidator
80+
{
81+
public async Task<LicenseValidationResult> ValidateAtStartupAsync(CancellationToken cancellationToken = default)
82+
{
83+
var license = await source.GetLicenseAsync(cancellationToken).ConfigureAwait(false);
84+
var result = validator.Validate(license, DateTimeOffset.UtcNow, options);
85+
86+
var top = result.Items.FirstOrDefault() ?? new LicenseStatusItem(result.OverallLevel, "license.status", "License status generated.");
87+
reporter.Report(new LicenseRuntimeStatus(DateTimeOffset.UtcNow, LicenseCheckStage.Startup, result.OverallLevel, top.Code, top.Message));
88+
89+
if (options.RequireValidLicenseAtStartup && !result.IsValid)
90+
{
91+
throw new InvalidOperationException("Code311 startup license validation failed.");
92+
}
93+
94+
return result;
95+
}
96+
}
97+
98+
public sealed class LicenseFeatureGate(
99+
ILicenseSource source,
100+
ILicenseValidator validator,
101+
ILicensingStatusReporter reporter,
102+
LicensingOptions options) : ILicenseFeatureGate
103+
{
104+
public async Task<LicenseFeatureCheckResult> CheckFeatureAsync(string feature, CancellationToken cancellationToken = default)
105+
{
106+
ArgumentException.ThrowIfNullOrWhiteSpace(feature);
107+
108+
var license = await source.GetLicenseAsync(cancellationToken).ConfigureAwait(false);
109+
var validation = validator.Validate(license, DateTimeOffset.UtcNow, options);
110+
111+
if (!validation.IsValid || validation.License is null)
112+
{
113+
var denied = new LicenseFeatureCheckResult(false, feature, LicenseStatusLevel.Error, "License invalid for feature check.", license);
114+
reporter.Report(new LicenseRuntimeStatus(DateTimeOffset.UtcNow, LicenseCheckStage.RuntimeFeature, denied.Level, "feature.denied.invalid_license", denied.Reason));
115+
return denied;
116+
}
117+
118+
if (!validation.License.Features.Contains(feature))
119+
{
120+
var denied = new LicenseFeatureCheckResult(false, feature, LicenseStatusLevel.Warning, "Feature not covered by current license.", validation.License);
121+
reporter.Report(new LicenseRuntimeStatus(DateTimeOffset.UtcNow, LicenseCheckStage.RuntimeFeature, denied.Level, "feature.denied.not_licensed", denied.Reason));
122+
return denied;
123+
}
124+
125+
var allowed = new LicenseFeatureCheckResult(true, feature, validation.OverallLevel, "Feature is licensed.", validation.License);
126+
reporter.Report(new LicenseRuntimeStatus(DateTimeOffset.UtcNow, LicenseCheckStage.RuntimeFeature, allowed.Level, "feature.allowed", allowed.Reason));
127+
return allowed;
128+
}
129+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
<PropertyGroup>
3+
<TargetFramework>net10.0</TargetFramework>
4+
<IsTestProject>true</IsTestProject>
5+
</PropertyGroup>
6+
7+
<ItemGroup>
8+
<PackageReference Include="Microsoft.NET.Test.Sdk" />
9+
<PackageReference Include="xunit" />
10+
<PackageReference Include="xunit.runner.visualstudio" />
11+
<PackageReference Include="coverlet.collector" />
12+
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
13+
</ItemGroup>
14+
15+
<ItemGroup>
16+
<ProjectReference Include="..\..\src\Code311.Licensing\Code311.Licensing.csproj" />
17+
</ItemGroup>
18+
</Project>

0 commit comments

Comments
 (0)