diff --git a/SimpleModule.slnx b/SimpleModule.slnx index f31109b6..672e2baa 100644 --- a/SimpleModule.slnx +++ b/SimpleModule.slnx @@ -114,6 +114,11 @@ + + + + + diff --git a/framework/SimpleModule.Core/IModule.cs b/framework/SimpleModule.Core/IModule.cs index e9541d08..5457425e 100644 --- a/framework/SimpleModule.Core/IModule.cs +++ b/framework/SimpleModule.Core/IModule.cs @@ -7,6 +7,7 @@ using SimpleModule.Core.Authorization; using SimpleModule.Core.FeatureFlags; using SimpleModule.Core.Menu; +using SimpleModule.Core.RateLimiting; using SimpleModule.Core.Settings; namespace SimpleModule.Core; @@ -21,6 +22,7 @@ virtual void ConfigurePermissions(PermissionRegistryBuilder builder) { } virtual void ConfigureSettings(ISettingsBuilder settings) { } virtual void ConfigureFeatureFlags(IFeatureFlagBuilder builder) { } virtual void ConfigureAgents(IAgentBuilder builder) { } + virtual void ConfigureRateLimits(IRateLimitBuilder builder) { } /// /// Called once during application startup after all services are built but before diff --git a/framework/SimpleModule.Core/RateLimiting/EndpointRateLimitExtensions.cs b/framework/SimpleModule.Core/RateLimiting/EndpointRateLimitExtensions.cs new file mode 100644 index 00000000..440a737f --- /dev/null +++ b/framework/SimpleModule.Core/RateLimiting/EndpointRateLimitExtensions.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Builder; + +namespace SimpleModule.Core.RateLimiting; + +public static class EndpointRateLimitExtensions +{ + public static TBuilder RateLimit(this TBuilder builder, string policyName) + where TBuilder : IEndpointConventionBuilder + { + builder.RequireRateLimiting(policyName); + return builder; + } +} diff --git a/framework/SimpleModule.Core/RateLimiting/IRateLimitBuilder.cs b/framework/SimpleModule.Core/RateLimiting/IRateLimitBuilder.cs new file mode 100644 index 00000000..1be0784a --- /dev/null +++ b/framework/SimpleModule.Core/RateLimiting/IRateLimitBuilder.cs @@ -0,0 +1,6 @@ +namespace SimpleModule.Core.RateLimiting; + +public interface IRateLimitBuilder +{ + IRateLimitBuilder Add(RateLimitPolicyDefinition policy); +} diff --git a/framework/SimpleModule.Core/RateLimiting/IRateLimitPolicyRegistry.cs b/framework/SimpleModule.Core/RateLimiting/IRateLimitPolicyRegistry.cs new file mode 100644 index 00000000..9e329397 --- /dev/null +++ b/framework/SimpleModule.Core/RateLimiting/IRateLimitPolicyRegistry.cs @@ -0,0 +1,7 @@ +namespace SimpleModule.Core.RateLimiting; + +public interface IRateLimitPolicyRegistry +{ + IReadOnlyList GetPolicies(); + RateLimitPolicyDefinition? GetPolicy(string name); +} diff --git a/framework/SimpleModule.Core/RateLimiting/RateLimitPolicyDefinition.cs b/framework/SimpleModule.Core/RateLimiting/RateLimitPolicyDefinition.cs new file mode 100644 index 00000000..04be0cb8 --- /dev/null +++ b/framework/SimpleModule.Core/RateLimiting/RateLimitPolicyDefinition.cs @@ -0,0 +1,15 @@ +namespace SimpleModule.Core.RateLimiting; + +public sealed class RateLimitPolicyDefinition +{ + public required string Name { get; init; } + public RateLimitPolicyType PolicyType { get; init; } = RateLimitPolicyType.FixedWindow; + public RateLimitTarget Target { get; init; } = RateLimitTarget.Ip; + public int PermitLimit { get; init; } = 60; + public TimeSpan Window { get; init; } = TimeSpan.FromMinutes(1); + public int SegmentsPerWindow { get; init; } = 4; + public int TokenLimit { get; init; } = 100; + public int TokensPerPeriod { get; init; } = 10; + public TimeSpan ReplenishmentPeriod { get; init; } = TimeSpan.FromSeconds(10); + public int QueueLimit { get; init; } +} diff --git a/framework/SimpleModule.Core/RateLimiting/RateLimitPolicyRegistry.cs b/framework/SimpleModule.Core/RateLimiting/RateLimitPolicyRegistry.cs new file mode 100644 index 00000000..02556d94 --- /dev/null +++ b/framework/SimpleModule.Core/RateLimiting/RateLimitPolicyRegistry.cs @@ -0,0 +1,19 @@ +namespace SimpleModule.Core.RateLimiting; + +public sealed class RateLimitPolicyRegistry : IRateLimitBuilder, IRateLimitPolicyRegistry +{ + private readonly Dictionary _policies = new( + StringComparer.OrdinalIgnoreCase + ); + + public IRateLimitBuilder Add(RateLimitPolicyDefinition policy) + { + _policies[policy.Name] = policy; + return this; + } + + public IReadOnlyList GetPolicies() => + _policies.Values.ToList().AsReadOnly(); + + public RateLimitPolicyDefinition? GetPolicy(string name) => _policies.GetValueOrDefault(name); +} diff --git a/framework/SimpleModule.Core/RateLimiting/RateLimitPolicyType.cs b/framework/SimpleModule.Core/RateLimiting/RateLimitPolicyType.cs new file mode 100644 index 00000000..653a178c --- /dev/null +++ b/framework/SimpleModule.Core/RateLimiting/RateLimitPolicyType.cs @@ -0,0 +1,8 @@ +namespace SimpleModule.Core.RateLimiting; + +public enum RateLimitPolicyType +{ + FixedWindow, + SlidingWindow, + TokenBucket, +} diff --git a/framework/SimpleModule.Core/RateLimiting/RateLimitTarget.cs b/framework/SimpleModule.Core/RateLimiting/RateLimitTarget.cs new file mode 100644 index 00000000..ff182209 --- /dev/null +++ b/framework/SimpleModule.Core/RateLimiting/RateLimitTarget.cs @@ -0,0 +1,9 @@ +namespace SimpleModule.Core.RateLimiting; + +public enum RateLimitTarget +{ + Ip, + User, + IpAndUser, + Global, +} diff --git a/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs b/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs index 2f989905..95c617e7 100644 --- a/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs +++ b/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs @@ -126,6 +126,7 @@ internal readonly record struct ModuleInfoRecord( bool HasConfigureSettings, bool HasConfigureFeatureFlags, bool HasConfigureAgents, + bool HasConfigureRateLimits, bool HasRazorComponents, string RoutePrefix, string ViewPrefix, @@ -146,6 +147,7 @@ public bool Equals(ModuleInfoRecord other) && HasConfigureSettings == other.HasConfigureSettings && HasConfigureFeatureFlags == other.HasConfigureFeatureFlags && HasConfigureAgents == other.HasConfigureAgents + && HasConfigureRateLimits == other.HasConfigureRateLimits && HasRazorComponents == other.HasRazorComponents && RoutePrefix == other.RoutePrefix && ViewPrefix == other.ViewPrefix @@ -167,6 +169,7 @@ public override int GetHashCode() hash = HashHelper.Combine(hash, HasConfigureSettings.GetHashCode()); hash = HashHelper.Combine(hash, HasConfigureFeatureFlags.GetHashCode()); hash = HashHelper.Combine(hash, HasConfigureAgents.GetHashCode()); + hash = HashHelper.Combine(hash, HasConfigureRateLimits.GetHashCode()); hash = HashHelper.Combine(hash, HasRazorComponents.GetHashCode()); hash = HashHelper.Combine(hash, (RoutePrefix ?? "").GetHashCode()); hash = HashHelper.Combine(hash, (ViewPrefix ?? "").GetHashCode()); @@ -462,6 +465,7 @@ internal sealed class ModuleInfo public bool HasConfigureSettings { get; set; } public bool HasConfigureFeatureFlags { get; set; } public bool HasConfigureAgents { get; set; } + public bool HasConfigureRateLimits { get; set; } public bool HasRazorComponents { get; set; } public string RoutePrefix { get; set; } = ""; public string ViewPrefix { get; set; } = ""; diff --git a/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs index b5b7a473..b9149fa9 100644 --- a/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs +++ b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs @@ -683,6 +683,7 @@ is not IAssemblySymbol assemblySymbol m.HasConfigureSettings, m.HasConfigureFeatureFlags, m.HasConfigureAgents, + m.HasConfigureRateLimits, m.HasRazorComponents, m.RoutePrefix, m.ViewPrefix, @@ -910,6 +911,10 @@ moduleSettingsSymbol is not null "ConfigureFeatureFlags" ), HasConfigureAgents = DeclaresMethod(typeSymbol, "ConfigureAgents"), + HasConfigureRateLimits = DeclaresMethod( + typeSymbol, + "ConfigureRateLimits" + ), RoutePrefix = routePrefix, ViewPrefix = viewPrefix, Location = GetSourceLocation(typeSymbol), diff --git a/framework/SimpleModule.Generator/Emitters/ModuleExtensionsEmitter.cs b/framework/SimpleModule.Generator/Emitters/ModuleExtensionsEmitter.cs index 984102fe..2ebbd3d1 100644 --- a/framework/SimpleModule.Generator/Emitters/ModuleExtensionsEmitter.cs +++ b/framework/SimpleModule.Generator/Emitters/ModuleExtensionsEmitter.cs @@ -205,6 +205,21 @@ public void Emit(SourceProductionContext context, DiscoveryData data) " services.AddSingleton(featureFlagBuilder.Build());" ); + // Collect rate limit policy definitions from modules + sb.AppendLine( + " var rateLimitRegistry = new SimpleModule.Core.RateLimiting.RateLimitPolicyRegistry();" + ); + foreach (var module in sortedModules.Where(m => m.HasConfigureRateLimits)) + { + var fieldName = TypeMappingHelpers.GetModuleFieldName(module.FullyQualifiedName); + sb.AppendLine( + $" ((global::SimpleModule.Core.IModule){fieldName}).ConfigureRateLimits(rateLimitRegistry);" + ); + } + sb.AppendLine( + " SimpleModule.Hosting.RateLimiting.RateLimitingSetup.AddSimpleModuleRateLimiting(services, rateLimitRegistry);" + ); + if (hasDtoTypes) { sb.AppendLine(); diff --git a/framework/SimpleModule.Hosting/RateLimiting/RateLimitHeaderMiddleware.cs b/framework/SimpleModule.Hosting/RateLimiting/RateLimitHeaderMiddleware.cs new file mode 100644 index 00000000..16465432 --- /dev/null +++ b/framework/SimpleModule.Hosting/RateLimiting/RateLimitHeaderMiddleware.cs @@ -0,0 +1,41 @@ +using System.Globalization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.RateLimiting; +using SimpleModule.Core.RateLimiting; + +namespace SimpleModule.Hosting.RateLimiting; + +public sealed class RateLimitHeaderMiddleware( + RequestDelegate next, + IRateLimitPolicyRegistry registry +) +{ + public async Task InvokeAsync(HttpContext context) + { + var endpoint = context.GetEndpoint(); + var rateLimitMetadata = endpoint?.Metadata.GetMetadata(); + + if (rateLimitMetadata is { PolicyName: { } policyName }) + { + var policy = registry.GetPolicy(policyName); + if (policy is not null) + { + context.Response.OnStarting(() => + { + if (context.Response.StatusCode != StatusCodes.Status429TooManyRequests) + { + context.Response.Headers["X-RateLimit-Policy"] = policy.Name; + context.Response.Headers["X-RateLimit-Limit"] = + policy.PolicyType == RateLimitPolicyType.TokenBucket + ? policy.TokenLimit.ToString(CultureInfo.InvariantCulture) + : policy.PermitLimit.ToString(CultureInfo.InvariantCulture); + } + + return Task.CompletedTask; + }); + } + } + + await next(context); + } +} diff --git a/framework/SimpleModule.Hosting/RateLimiting/RateLimitingSetup.cs b/framework/SimpleModule.Hosting/RateLimiting/RateLimitingSetup.cs new file mode 100644 index 00000000..81805e83 --- /dev/null +++ b/framework/SimpleModule.Hosting/RateLimiting/RateLimitingSetup.cs @@ -0,0 +1,143 @@ +using System.Globalization; +using System.Security.Claims; +using System.Threading.RateLimiting; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.Extensions.DependencyInjection; +using SimpleModule.Core.RateLimiting; + +namespace SimpleModule.Hosting.RateLimiting; + +public static class RateLimitingSetup +{ + public static IServiceCollection AddSimpleModuleRateLimiting( + this IServiceCollection services, + IRateLimitPolicyRegistry registry + ) + { + services.AddSingleton(registry); + + services.AddRateLimiter(options => + { + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + + options.OnRejected = async (context, cancellationToken) => + { + context.HttpContext.Response.Headers["Retry-After"] = context.Lease.TryGetMetadata( + MetadataName.RetryAfter, + out var retryAfter + ) + ? ((int)retryAfter.TotalSeconds).ToString(CultureInfo.InvariantCulture) + : "60"; + + context.HttpContext.Response.ContentType = "application/problem+json"; + await context.HttpContext.Response.WriteAsync( + """{"type":"https://httpstatuses.io/429","title":"Too Many Requests","status":429,"detail":"Rate limit exceeded. Please retry after the period indicated in the Retry-After header."}""", + cancellationToken + ); + }; + + foreach (var policy in registry.GetPolicies()) + { + RegisterPolicy(options, policy); + } + }); + + return services; + } + + public static WebApplication UseSimpleModuleRateLimiting(this WebApplication app) + { + app.UseMiddleware(); + app.UseRateLimiter(); + return app; + } + + private static void RegisterPolicy(RateLimiterOptions options, RateLimitPolicyDefinition policy) + { + switch (policy.PolicyType) + { + case RateLimitPolicyType.FixedWindow: + { + var limiterOptions = new FixedWindowRateLimiterOptions + { + PermitLimit = policy.PermitLimit, + Window = policy.Window, + QueueLimit = policy.QueueLimit, + QueueProcessingOrder = QueueProcessingOrder.OldestFirst, + }; + options.AddPolicy( + policy.Name, + context => + RateLimitPartition.GetFixedWindowLimiter( + ResolvePartitionKey(context, policy.Target), + _ => limiterOptions + ) + ); + break; + } + case RateLimitPolicyType.SlidingWindow: + { + var limiterOptions = new SlidingWindowRateLimiterOptions + { + PermitLimit = policy.PermitLimit, + Window = policy.Window, + SegmentsPerWindow = policy.SegmentsPerWindow, + QueueLimit = policy.QueueLimit, + QueueProcessingOrder = QueueProcessingOrder.OldestFirst, + }; + options.AddPolicy( + policy.Name, + context => + RateLimitPartition.GetSlidingWindowLimiter( + ResolvePartitionKey(context, policy.Target), + _ => limiterOptions + ) + ); + break; + } + case RateLimitPolicyType.TokenBucket: + { + var limiterOptions = new TokenBucketRateLimiterOptions + { + TokenLimit = policy.TokenLimit, + TokensPerPeriod = policy.TokensPerPeriod, + ReplenishmentPeriod = policy.ReplenishmentPeriod, + QueueLimit = policy.QueueLimit, + QueueProcessingOrder = QueueProcessingOrder.OldestFirst, + }; + options.AddPolicy( + policy.Name, + context => + RateLimitPartition.GetTokenBucketLimiter( + ResolvePartitionKey(context, policy.Target), + _ => limiterOptions + ) + ); + break; + } + default: + options.AddPolicy( + policy.Name, + context => + RateLimitPartition.GetNoLimiter(ResolvePartitionKey(context, policy.Target)) + ); + break; + } + } + + private static string ResolvePartitionKey(HttpContext context, RateLimitTarget target) + { + return target switch + { + RateLimitTarget.Ip => context.Connection.RemoteIpAddress?.ToString() ?? "unknown", + RateLimitTarget.User => context.User.FindFirstValue(ClaimTypes.NameIdentifier) + ?? "anonymous", + RateLimitTarget.IpAndUser => + $"{context.Connection.RemoteIpAddress}:{context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "anonymous"}", + RateLimitTarget.Global => "__global__", + _ => context.Connection.RemoteIpAddress?.ToString() ?? "unknown", + }; + } +} diff --git a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs index f1c210b7..0ad1b32a 100644 --- a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs +++ b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs @@ -14,10 +14,12 @@ using SimpleModule.Core.Exceptions; using SimpleModule.Core.Inertia; using SimpleModule.Core.Menu; +using SimpleModule.Core.RateLimiting; using SimpleModule.Database; using SimpleModule.Database.Health; using SimpleModule.Database.Interceptors; using SimpleModule.DevTools; +using SimpleModule.Hosting.RateLimiting; namespace SimpleModule.Hosting; @@ -157,6 +159,7 @@ public static async Task UseSimpleModuleInfrastructure(this WebApplication app) app.UseAuthentication(); app.UseAuthorization(); + app.UseSimpleModuleRateLimiting(); // Module middleware is added by the source-generated UseSimpleModule() // via IModule.ConfigureMiddleware() calls. diff --git a/modules/RateLimiting/src/SimpleModule.RateLimiting.Contracts/CreateRateLimitRuleRequest.cs b/modules/RateLimiting/src/SimpleModule.RateLimiting.Contracts/CreateRateLimitRuleRequest.cs new file mode 100644 index 00000000..9849a7cf --- /dev/null +++ b/modules/RateLimiting/src/SimpleModule.RateLimiting.Contracts/CreateRateLimitRuleRequest.cs @@ -0,0 +1,19 @@ +using SimpleModule.Core.RateLimiting; + +namespace SimpleModule.RateLimiting.Contracts; + +public class CreateRateLimitRuleRequest +{ + public string PolicyName { get; set; } = string.Empty; + public RateLimitPolicyType PolicyType { get; set; } = RateLimitPolicyType.FixedWindow; + public RateLimitTarget Target { get; set; } = RateLimitTarget.Ip; + public int PermitLimit { get; set; } = 60; + public int WindowSeconds { get; set; } = 60; + public int SegmentsPerWindow { get; set; } = 4; + public int TokenLimit { get; set; } = 100; + public int TokensPerPeriod { get; set; } = 10; + public int ReplenishmentPeriodSeconds { get; set; } = 10; + public int QueueLimit { get; set; } + public string? EndpointPattern { get; set; } + public bool IsEnabled { get; set; } = true; +} diff --git a/modules/RateLimiting/src/SimpleModule.RateLimiting.Contracts/IRateLimitingContracts.cs b/modules/RateLimiting/src/SimpleModule.RateLimiting.Contracts/IRateLimitingContracts.cs new file mode 100644 index 00000000..0fe75c6a --- /dev/null +++ b/modules/RateLimiting/src/SimpleModule.RateLimiting.Contracts/IRateLimitingContracts.cs @@ -0,0 +1,10 @@ +namespace SimpleModule.RateLimiting.Contracts; + +public interface IRateLimitingContracts +{ + Task> GetAllRulesAsync(); + Task GetRuleByIdAsync(RateLimitRuleId id); + Task CreateRuleAsync(CreateRateLimitRuleRequest request); + Task UpdateRuleAsync(RateLimitRuleId id, UpdateRateLimitRuleRequest request); + Task DeleteRuleAsync(RateLimitRuleId id); +} diff --git a/modules/RateLimiting/src/SimpleModule.RateLimiting.Contracts/RateLimitRule.cs b/modules/RateLimiting/src/SimpleModule.RateLimiting.Contracts/RateLimitRule.cs new file mode 100644 index 00000000..8260f3be --- /dev/null +++ b/modules/RateLimiting/src/SimpleModule.RateLimiting.Contracts/RateLimitRule.cs @@ -0,0 +1,22 @@ +using SimpleModule.Core.RateLimiting; + +namespace SimpleModule.RateLimiting.Contracts; + +public class RateLimitRule +{ + public RateLimitRuleId Id { get; set; } + public string PolicyName { get; set; } = string.Empty; + public RateLimitPolicyType PolicyType { get; set; } = RateLimitPolicyType.FixedWindow; + public RateLimitTarget Target { get; set; } = RateLimitTarget.Ip; + public int PermitLimit { get; set; } = 60; + public int WindowSeconds { get; set; } = 60; + public int SegmentsPerWindow { get; set; } = 4; + public int TokenLimit { get; set; } = 100; + public int TokensPerPeriod { get; set; } = 10; + public int ReplenishmentPeriodSeconds { get; set; } = 10; + public int QueueLimit { get; set; } + public string? EndpointPattern { get; set; } + public bool IsEnabled { get; set; } = true; + public DateTime CreatedAt { get; set; } + public DateTime? UpdatedAt { get; set; } +} diff --git a/modules/RateLimiting/src/SimpleModule.RateLimiting.Contracts/RateLimitRuleId.cs b/modules/RateLimiting/src/SimpleModule.RateLimiting.Contracts/RateLimitRuleId.cs new file mode 100644 index 00000000..0c3116cf --- /dev/null +++ b/modules/RateLimiting/src/SimpleModule.RateLimiting.Contracts/RateLimitRuleId.cs @@ -0,0 +1,6 @@ +using Vogen; + +namespace SimpleModule.RateLimiting.Contracts; + +[ValueObject(conversions: Conversions.SystemTextJson | Conversions.EfCoreValueConverter)] +public readonly partial struct RateLimitRuleId; diff --git a/modules/RateLimiting/src/SimpleModule.RateLimiting.Contracts/SimpleModule.RateLimiting.Contracts.csproj b/modules/RateLimiting/src/SimpleModule.RateLimiting.Contracts/SimpleModule.RateLimiting.Contracts.csproj new file mode 100644 index 00000000..97fdfa07 --- /dev/null +++ b/modules/RateLimiting/src/SimpleModule.RateLimiting.Contracts/SimpleModule.RateLimiting.Contracts.csproj @@ -0,0 +1,12 @@ + + + net10.0 + Library + $(DefineConstants);VOGEN_NO_VALIDATION + + + + + + + diff --git a/modules/RateLimiting/src/SimpleModule.RateLimiting.Contracts/UpdateRateLimitRuleRequest.cs b/modules/RateLimiting/src/SimpleModule.RateLimiting.Contracts/UpdateRateLimitRuleRequest.cs new file mode 100644 index 00000000..02e00695 --- /dev/null +++ b/modules/RateLimiting/src/SimpleModule.RateLimiting.Contracts/UpdateRateLimitRuleRequest.cs @@ -0,0 +1,18 @@ +using SimpleModule.Core.RateLimiting; + +namespace SimpleModule.RateLimiting.Contracts; + +public class UpdateRateLimitRuleRequest +{ + public RateLimitPolicyType PolicyType { get; set; } = RateLimitPolicyType.FixedWindow; + public RateLimitTarget Target { get; set; } = RateLimitTarget.Ip; + public int PermitLimit { get; set; } = 60; + public int WindowSeconds { get; set; } = 60; + public int SegmentsPerWindow { get; set; } = 4; + public int TokenLimit { get; set; } = 100; + public int TokensPerPeriod { get; set; } = 10; + public int ReplenishmentPeriodSeconds { get; set; } = 10; + public int QueueLimit { get; set; } + public string? EndpointPattern { get; set; } + public bool IsEnabled { get; set; } = true; +} diff --git a/modules/RateLimiting/src/SimpleModule.RateLimiting/Endpoints/Policies/CreateEndpoint.cs b/modules/RateLimiting/src/SimpleModule.RateLimiting/Endpoints/Policies/CreateEndpoint.cs new file mode 100644 index 00000000..987c0314 --- /dev/null +++ b/modules/RateLimiting/src/SimpleModule.RateLimiting/Endpoints/Policies/CreateEndpoint.cs @@ -0,0 +1,31 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.Core.Endpoints; +using SimpleModule.Core.Exceptions; +using SimpleModule.RateLimiting.Contracts; + +namespace SimpleModule.RateLimiting.Endpoints.Policies; + +public class CreateEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder app) => + app.MapPost( + "/", + (CreateRateLimitRuleRequest request, IRateLimitingContracts contracts) => + { + var validation = CreateRequestValidator.Validate(request); + if (!validation.IsValid) + { + throw new ValidationException(validation.Errors); + } + + return CrudEndpoints.Create( + () => contracts.CreateRuleAsync(request), + r => $"{RateLimitingConstants.RoutePrefix}/{r.Id}" + ); + } + ) + .RequirePermission(RateLimitingPermissions.Create); +} diff --git a/modules/RateLimiting/src/SimpleModule.RateLimiting/Endpoints/Policies/CreateRequestValidator.cs b/modules/RateLimiting/src/SimpleModule.RateLimiting/Endpoints/Policies/CreateRequestValidator.cs new file mode 100644 index 00000000..834e16bc --- /dev/null +++ b/modules/RateLimiting/src/SimpleModule.RateLimiting/Endpoints/Policies/CreateRequestValidator.cs @@ -0,0 +1,21 @@ +using SimpleModule.Core.Validation; +using SimpleModule.RateLimiting.Contracts; + +namespace SimpleModule.RateLimiting.Endpoints.Policies; + +public static class CreateRequestValidator +{ + public static ValidationResult Validate(CreateRateLimitRuleRequest request) => + new ValidationBuilder() + .AddErrorIf( + string.IsNullOrWhiteSpace(request.PolicyName), + "PolicyName", + "Policy name is required." + ) + .AddErrorIf( + request.PermitLimit <= 0, + "PermitLimit", + "Permit limit must be greater than zero." + ) + .Build(); +} diff --git a/modules/RateLimiting/src/SimpleModule.RateLimiting/Endpoints/Policies/DeleteEndpoint.cs b/modules/RateLimiting/src/SimpleModule.RateLimiting/Endpoints/Policies/DeleteEndpoint.cs new file mode 100644 index 00000000..60ed4054 --- /dev/null +++ b/modules/RateLimiting/src/SimpleModule.RateLimiting/Endpoints/Policies/DeleteEndpoint.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.Core.Endpoints; +using SimpleModule.RateLimiting.Contracts; + +namespace SimpleModule.RateLimiting.Endpoints.Policies; + +public class DeleteEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder app) => + app.MapDelete( + "/{id:int}", + (int id, IRateLimitingContracts contracts) => + CrudEndpoints.Delete(() => contracts.DeleteRuleAsync(RateLimitRuleId.From(id))) + ) + .RequirePermission(RateLimitingPermissions.Delete); +} diff --git a/modules/RateLimiting/src/SimpleModule.RateLimiting/Endpoints/Policies/GetActivePoliciesEndpoint.cs b/modules/RateLimiting/src/SimpleModule.RateLimiting/Endpoints/Policies/GetActivePoliciesEndpoint.cs new file mode 100644 index 00000000..31b30fb0 --- /dev/null +++ b/modules/RateLimiting/src/SimpleModule.RateLimiting/Endpoints/Policies/GetActivePoliciesEndpoint.cs @@ -0,0 +1,36 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.Core.RateLimiting; + +namespace SimpleModule.RateLimiting.Endpoints.Policies; + +public class GetActivePoliciesEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder app) => + app.MapGet( + "/active", + (IRateLimitPolicyRegistry registry) => + TypedResults.Ok( + registry + .GetPolicies() + .Select(p => new + { + p.Name, + PolicyType = p.PolicyType.ToString(), + Target = p.Target.ToString(), + p.PermitLimit, + WindowSeconds = (int)p.Window.TotalSeconds, + p.SegmentsPerWindow, + p.TokenLimit, + p.TokensPerPeriod, + ReplenishmentPeriodSeconds = (int) + p.ReplenishmentPeriod.TotalSeconds, + p.QueueLimit, + }) + ) + ) + .RequirePermission(RateLimitingPermissions.View); +} diff --git a/modules/RateLimiting/src/SimpleModule.RateLimiting/Endpoints/Policies/GetAllEndpoint.cs b/modules/RateLimiting/src/SimpleModule.RateLimiting/Endpoints/Policies/GetAllEndpoint.cs new file mode 100644 index 00000000..85a86090 --- /dev/null +++ b/modules/RateLimiting/src/SimpleModule.RateLimiting/Endpoints/Policies/GetAllEndpoint.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.Core.Endpoints; +using SimpleModule.RateLimiting.Contracts; + +namespace SimpleModule.RateLimiting.Endpoints.Policies; + +public class GetAllEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder app) => + app.MapGet( + "/", + (IRateLimitingContracts contracts) => + CrudEndpoints.GetAll(contracts.GetAllRulesAsync) + ) + .RequirePermission(RateLimitingPermissions.View); +} diff --git a/modules/RateLimiting/src/SimpleModule.RateLimiting/Endpoints/Policies/GetByIdEndpoint.cs b/modules/RateLimiting/src/SimpleModule.RateLimiting/Endpoints/Policies/GetByIdEndpoint.cs new file mode 100644 index 00000000..87b8151b --- /dev/null +++ b/modules/RateLimiting/src/SimpleModule.RateLimiting/Endpoints/Policies/GetByIdEndpoint.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.Core.Endpoints; +using SimpleModule.RateLimiting.Contracts; + +namespace SimpleModule.RateLimiting.Endpoints.Policies; + +public class GetByIdEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder app) => + app.MapGet( + "/{id:int}", + (int id, IRateLimitingContracts contracts) => + CrudEndpoints.GetById(() => + contracts.GetRuleByIdAsync(RateLimitRuleId.From(id)) + ) + ) + .RequirePermission(RateLimitingPermissions.View); +} diff --git a/modules/RateLimiting/src/SimpleModule.RateLimiting/Endpoints/Policies/UpdateEndpoint.cs b/modules/RateLimiting/src/SimpleModule.RateLimiting/Endpoints/Policies/UpdateEndpoint.cs new file mode 100644 index 00000000..6509c9ba --- /dev/null +++ b/modules/RateLimiting/src/SimpleModule.RateLimiting/Endpoints/Policies/UpdateEndpoint.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.Core.Endpoints; +using SimpleModule.RateLimiting.Contracts; + +namespace SimpleModule.RateLimiting.Endpoints.Policies; + +public class UpdateEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder app) => + app.MapPut( + "/{id:int}", + (int id, UpdateRateLimitRuleRequest request, IRateLimitingContracts contracts) => + CrudEndpoints.Update(() => + contracts.UpdateRuleAsync(RateLimitRuleId.From(id), request) + ) + ) + .RequirePermission(RateLimitingPermissions.Update); +} diff --git a/modules/RateLimiting/src/SimpleModule.RateLimiting/EntityConfigurations/RateLimitRuleConfiguration.cs b/modules/RateLimiting/src/SimpleModule.RateLimiting/EntityConfigurations/RateLimitRuleConfiguration.cs new file mode 100644 index 00000000..49bae630 --- /dev/null +++ b/modules/RateLimiting/src/SimpleModule.RateLimiting/EntityConfigurations/RateLimitRuleConfiguration.cs @@ -0,0 +1,19 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using SimpleModule.RateLimiting.Contracts; + +namespace SimpleModule.RateLimiting.EntityConfigurations; + +public class RateLimitRuleConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(r => r.Id); + builder.Property(r => r.Id).ValueGeneratedOnAdd(); + builder.Property(r => r.PolicyName).IsRequired().HasMaxLength(100); + builder.HasIndex(r => r.PolicyName).IsUnique(); + builder.Property(r => r.EndpointPattern).HasMaxLength(500); + builder.Property(r => r.PolicyType).HasConversion().HasMaxLength(50); + builder.Property(r => r.Target).HasConversion().HasMaxLength(50); + } +} diff --git a/modules/RateLimiting/src/SimpleModule.RateLimiting/Locales/en.json b/modules/RateLimiting/src/SimpleModule.RateLimiting/Locales/en.json new file mode 100644 index 00000000..5f7e387a --- /dev/null +++ b/modules/RateLimiting/src/SimpleModule.RateLimiting/Locales/en.json @@ -0,0 +1,11 @@ +{ + "Admin.Title": "Rate Limiting", + "Admin.Description": "Manage rate limiting policies for API endpoints.", + "Admin.CreateButton": "Create Rule", + "Admin.EmptyTitle": "No rate limit rules", + "Admin.EmptyDescription": "Get started by creating your first rate limit rule.", + "Admin.DeleteButton": "Delete", + "Admin.DeleteDialog.Title": "Delete Rate Limit Rule", + "Admin.DeleteDialog.Confirm": "Are you sure you want to delete \"{name}\"? This action cannot be undone.", + "Admin.CancelButton": "Cancel" +} diff --git a/modules/RateLimiting/src/SimpleModule.RateLimiting/Pages/index.ts b/modules/RateLimiting/src/SimpleModule.RateLimiting/Pages/index.ts new file mode 100644 index 00000000..1e632f74 --- /dev/null +++ b/modules/RateLimiting/src/SimpleModule.RateLimiting/Pages/index.ts @@ -0,0 +1,3 @@ +export const pages: Record = { + 'RateLimiting/Admin': () => import('../Views/Admin'), +}; diff --git a/modules/RateLimiting/src/SimpleModule.RateLimiting/RateLimitingConstants.cs b/modules/RateLimiting/src/SimpleModule.RateLimiting/RateLimitingConstants.cs new file mode 100644 index 00000000..fa286396 --- /dev/null +++ b/modules/RateLimiting/src/SimpleModule.RateLimiting/RateLimitingConstants.cs @@ -0,0 +1,7 @@ +namespace SimpleModule.RateLimiting; + +public static class RateLimitingConstants +{ + public const string ModuleName = "RateLimiting"; + public const string RoutePrefix = "/api/rate-limiting"; +} diff --git a/modules/RateLimiting/src/SimpleModule.RateLimiting/RateLimitingDbContext.cs b/modules/RateLimiting/src/SimpleModule.RateLimiting/RateLimitingDbContext.cs new file mode 100644 index 00000000..47a993d8 --- /dev/null +++ b/modules/RateLimiting/src/SimpleModule.RateLimiting/RateLimitingDbContext.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using SimpleModule.Database; +using SimpleModule.RateLimiting.Contracts; +using SimpleModule.RateLimiting.EntityConfigurations; + +namespace SimpleModule.RateLimiting; + +public class RateLimitingDbContext( + DbContextOptions options, + IOptions dbOptions +) : DbContext(options) +{ + public DbSet Rules => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ApplyConfiguration(new RateLimitRuleConfiguration()); + modelBuilder.ApplyModuleSchema("RateLimiting", dbOptions.Value); + } + + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) + { + configurationBuilder + .Properties() + .HaveConversion< + RateLimitRuleId.EfCoreValueConverter, + RateLimitRuleId.EfCoreValueComparer + >(); + } +} diff --git a/modules/RateLimiting/src/SimpleModule.RateLimiting/RateLimitingModule.cs b/modules/RateLimiting/src/SimpleModule.RateLimiting/RateLimitingModule.cs new file mode 100644 index 00000000..bac2707d --- /dev/null +++ b/modules/RateLimiting/src/SimpleModule.RateLimiting/RateLimitingModule.cs @@ -0,0 +1,86 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using SimpleModule.Core; +using SimpleModule.Core.Menu; +using SimpleModule.Core.RateLimiting; +using SimpleModule.Database; + +namespace SimpleModule.RateLimiting; + +[Module( + RateLimitingConstants.ModuleName, + RoutePrefix = RateLimitingConstants.RoutePrefix, + ViewPrefix = "/rate-limiting" +)] +public class RateLimitingModule : IModule, IModuleMenu +{ + public void ConfigureServices(IServiceCollection services, IConfiguration configuration) + { + services.AddModuleDbContext( + configuration, + RateLimitingConstants.ModuleName + ); + } + + public void ConfigureRateLimits(IRateLimitBuilder builder) + { + builder + .Add( + new RateLimitPolicyDefinition + { + Name = "fixed-default", + PolicyType = RateLimitPolicyType.FixedWindow, + Target = RateLimitTarget.Ip, + PermitLimit = 60, + Window = TimeSpan.FromMinutes(1), + } + ) + .Add( + new RateLimitPolicyDefinition + { + Name = "sliding-strict", + PolicyType = RateLimitPolicyType.SlidingWindow, + Target = RateLimitTarget.IpAndUser, + PermitLimit = 30, + Window = TimeSpan.FromMinutes(1), + SegmentsPerWindow = 6, + } + ) + .Add( + new RateLimitPolicyDefinition + { + Name = "token-bucket", + PolicyType = RateLimitPolicyType.TokenBucket, + Target = RateLimitTarget.Ip, + TokenLimit = 100, + TokensPerPeriod = 10, + ReplenishmentPeriod = TimeSpan.FromSeconds(10), + } + ) + .Add( + new RateLimitPolicyDefinition + { + Name = "auth-strict", + PolicyType = RateLimitPolicyType.FixedWindow, + Target = RateLimitTarget.Ip, + PermitLimit = 10, + Window = TimeSpan.FromMinutes(1), + } + ); + } + + public void ConfigureMenu(IMenuBuilder menus) + { + menus.Add( + new MenuItem + { + Label = "Rate Limiting", + Url = "/rate-limiting", + Icon = + """""", + Order = 85, + Section = MenuSection.AdminSidebar, + } + ); + } +} diff --git a/modules/RateLimiting/src/SimpleModule.RateLimiting/RateLimitingPermissions.cs b/modules/RateLimiting/src/SimpleModule.RateLimiting/RateLimitingPermissions.cs new file mode 100644 index 00000000..ad28677d --- /dev/null +++ b/modules/RateLimiting/src/SimpleModule.RateLimiting/RateLimitingPermissions.cs @@ -0,0 +1,11 @@ +using SimpleModule.Core.Authorization; + +namespace SimpleModule.RateLimiting; + +public sealed class RateLimitingPermissions : IModulePermissions +{ + public const string View = "RateLimiting.View"; + public const string Create = "RateLimiting.Create"; + public const string Update = "RateLimiting.Update"; + public const string Delete = "RateLimiting.Delete"; +} diff --git a/modules/RateLimiting/src/SimpleModule.RateLimiting/RateLimitingService.cs b/modules/RateLimiting/src/SimpleModule.RateLimiting/RateLimitingService.cs new file mode 100644 index 00000000..2a4885a2 --- /dev/null +++ b/modules/RateLimiting/src/SimpleModule.RateLimiting/RateLimitingService.cs @@ -0,0 +1,126 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using SimpleModule.RateLimiting.Contracts; + +namespace SimpleModule.RateLimiting; + +public partial class RateLimitingService( + RateLimitingDbContext db, + ILogger logger +) : IRateLimitingContracts +{ + public async Task> GetAllRulesAsync() => + await db.Rules.AsNoTracking().OrderBy(r => r.PolicyName).ToListAsync(); + + public async Task GetRuleByIdAsync(RateLimitRuleId id) + { + var rule = await db.Rules.FindAsync(id); + if (rule is null) + { + LogRuleNotFound(logger, id); + } + + return rule; + } + + public async Task CreateRuleAsync(CreateRateLimitRuleRequest request) + { + var rule = new RateLimitRule + { + PolicyName = request.PolicyName, + PolicyType = request.PolicyType, + Target = request.Target, + PermitLimit = request.PermitLimit, + WindowSeconds = request.WindowSeconds, + SegmentsPerWindow = request.SegmentsPerWindow, + TokenLimit = request.TokenLimit, + TokensPerPeriod = request.TokensPerPeriod, + ReplenishmentPeriodSeconds = request.ReplenishmentPeriodSeconds, + QueueLimit = request.QueueLimit, + EndpointPattern = request.EndpointPattern, + IsEnabled = request.IsEnabled, + CreatedAt = DateTime.UtcNow, + }; + + db.Rules.Add(rule); + await db.SaveChangesAsync(); + + LogRuleCreated(logger, rule.Id, rule.PolicyName); + + return rule; + } + + public async Task UpdateRuleAsync( + RateLimitRuleId id, + UpdateRateLimitRuleRequest request + ) + { + var rule = await db.Rules.FindAsync(id); + if (rule is null) + { + throw new Core.Exceptions.NotFoundException("RateLimitRule", id); + } + + rule.PolicyType = request.PolicyType; + rule.Target = request.Target; + rule.PermitLimit = request.PermitLimit; + rule.WindowSeconds = request.WindowSeconds; + rule.SegmentsPerWindow = request.SegmentsPerWindow; + rule.TokenLimit = request.TokenLimit; + rule.TokensPerPeriod = request.TokensPerPeriod; + rule.ReplenishmentPeriodSeconds = request.ReplenishmentPeriodSeconds; + rule.QueueLimit = request.QueueLimit; + rule.EndpointPattern = request.EndpointPattern; + rule.IsEnabled = request.IsEnabled; + rule.UpdatedAt = DateTime.UtcNow; + + await db.SaveChangesAsync(); + + LogRuleUpdated(logger, rule.Id, rule.PolicyName); + + return rule; + } + + public async Task DeleteRuleAsync(RateLimitRuleId id) + { + var rule = await db.Rules.FindAsync(id); + if (rule is null) + { + throw new Core.Exceptions.NotFoundException("RateLimitRule", id); + } + + db.Rules.Remove(rule); + await db.SaveChangesAsync(); + + LogRuleDeleted(logger, id); + } + + [LoggerMessage( + Level = LogLevel.Warning, + Message = "Rate limit rule with ID {RuleId} not found" + )] + private static partial void LogRuleNotFound(ILogger logger, RateLimitRuleId ruleId); + + [LoggerMessage( + Level = LogLevel.Information, + Message = "Rate limit rule {RuleId} created: {PolicyName}" + )] + private static partial void LogRuleCreated( + ILogger logger, + RateLimitRuleId ruleId, + string policyName + ); + + [LoggerMessage( + Level = LogLevel.Information, + Message = "Rate limit rule {RuleId} updated: {PolicyName}" + )] + private static partial void LogRuleUpdated( + ILogger logger, + RateLimitRuleId ruleId, + string policyName + ); + + [LoggerMessage(Level = LogLevel.Information, Message = "Rate limit rule {RuleId} deleted")] + private static partial void LogRuleDeleted(ILogger logger, RateLimitRuleId ruleId); +} diff --git a/modules/RateLimiting/src/SimpleModule.RateLimiting/SimpleModule.RateLimiting.csproj b/modules/RateLimiting/src/SimpleModule.RateLimiting/SimpleModule.RateLimiting.csproj new file mode 100644 index 00000000..d68b733a --- /dev/null +++ b/modules/RateLimiting/src/SimpleModule.RateLimiting/SimpleModule.RateLimiting.csproj @@ -0,0 +1,19 @@ + + + net10.0 + + + + + + + + + + + + + %(Filename)Endpoint.cs + + + diff --git a/modules/RateLimiting/src/SimpleModule.RateLimiting/Views/Admin.tsx b/modules/RateLimiting/src/SimpleModule.RateLimiting/Views/Admin.tsx new file mode 100644 index 00000000..c8b5d5fa --- /dev/null +++ b/modules/RateLimiting/src/SimpleModule.RateLimiting/Views/Admin.tsx @@ -0,0 +1,404 @@ +import { router } from '@inertiajs/react'; +import { + Badge, + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, + Input, + Label, + PageShell, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Switch, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from '@simplemodule/ui'; +import { useState } from 'react'; + +interface RateLimitRule { + id: number; + policyName: string; + policyType: string; + target: string; + permitLimit: number; + windowSeconds: number; + segmentsPerWindow: number; + tokenLimit: number; + tokensPerPeriod: number; + replenishmentPeriodSeconds: number; + queueLimit: number; + endpointPattern: string | null; + isEnabled: boolean; + createdAt: string; + updatedAt: string | null; +} + +interface ActivePolicy { + name: string; + policyType: string; + target: string; + permitLimit: number; + windowSeconds: number; + tokenLimit: number; + tokensPerPeriod: number; +} + +interface AdminProps { + rules: RateLimitRule[]; + activePolicies: ActivePolicy[]; +} + +const policyTypes = ['FixedWindow', 'SlidingWindow', 'TokenBucket']; +const targets = ['Ip', 'User', 'IpAndUser', 'Global']; +const API_BASE = '/api/rate-limiting'; + +function PolicyTypeBadge({ type }: { type: string }) { + const variant = + type === 'TokenBucket' ? 'secondary' : type === 'SlidingWindow' ? 'outline' : 'default'; + return {type}; +} + +function TargetBadge({ target }: { target: string }) { + return {target}; +} + +export default function Admin({ rules, activePolicies }: AdminProps) { + const [showCreateDialog, setShowCreateDialog] = useState(false); + const [formData, setFormData] = useState({ + policyName: '', + policyType: 'FixedWindow', + target: 'Ip', + permitLimit: 60, + windowSeconds: 60, + segmentsPerWindow: 4, + tokenLimit: 100, + tokensPerPeriod: 10, + replenishmentPeriodSeconds: 10, + queueLimit: 0, + endpointPattern: '', + isEnabled: true, + }); + + const handleCreate = async () => { + await fetch(API_BASE, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(formData), + }); + setShowCreateDialog(false); + router.reload(); + }; + + const handleDelete = async (id: number) => { + await fetch(`${API_BASE}/${id}`, { method: 'DELETE' }); + router.reload(); + }; + + const handleToggle = async (rule: RateLimitRule) => { + await fetch(`${API_BASE}/${rule.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + policyType: rule.policyType, + target: rule.target, + permitLimit: rule.permitLimit, + windowSeconds: rule.windowSeconds, + segmentsPerWindow: rule.segmentsPerWindow, + tokenLimit: rule.tokenLimit, + tokensPerPeriod: rule.tokensPerPeriod, + replenishmentPeriodSeconds: rule.replenishmentPeriodSeconds, + queueLimit: rule.queueLimit, + endpointPattern: rule.endpointPattern, + isEnabled: !rule.isEnabled, + }), + }); + router.reload(); + }; + + return ( + + + + Stored Rules + Active Policies + + + + + + + + Rate Limit Rules + + Manage rate limiting policies stored in the database. + + + + + Create Rule + + + + Create Rate Limit Rule + + + + Policy Name + + setFormData((prev) => ({ ...prev, policyName: e.target.value })) + } + placeholder="e.g., api-default" + /> + + + + Policy Type + + setFormData((prev) => ({ ...prev, policyType: v })) + } + > + + + + + {policyTypes.map((t) => ( + + {t} + + ))} + + + + + Target + setFormData((prev) => ({ ...prev, target: v }))} + > + + + + + {targets.map((t) => ( + + {t} + + ))} + + + + + {formData.policyType !== 'TokenBucket' && ( + + + Permit Limit + + setFormData((prev) => ({ + ...prev, + permitLimit: Number.parseInt(e.target.value, 10), + })) + } + /> + + + Window (seconds) + + setFormData((prev) => ({ + ...prev, + windowSeconds: Number.parseInt(e.target.value, 10), + })) + } + /> + + + )} + {formData.policyType === 'TokenBucket' && ( + + + Token Limit + + setFormData((prev) => ({ + ...prev, + tokenLimit: Number.parseInt(e.target.value, 10), + })) + } + /> + + + Tokens Per Period + + setFormData((prev) => ({ + ...prev, + tokensPerPeriod: Number.parseInt(e.target.value, 10), + })) + } + /> + + + )} + + Endpoint Pattern (optional) + + setFormData((prev) => ({ ...prev, endpointPattern: e.target.value })) + } + placeholder="e.g., /api/products/*" + /> + + Create + + + + + + + {rules.length === 0 ? ( + + No rate limit rules configured yet. + + ) : ( + + + + Policy Name + Type + Target + Limit + Endpoint + Enabled + + + + + {rules.map((rule) => ( + + {rule.policyName} + + + + + + + + {rule.policyType === 'TokenBucket' + ? `${rule.tokenLimit} tokens` + : `${rule.permitLimit} req/${rule.windowSeconds}s`} + + + {rule.endpointPattern ?? 'All'} + + + handleToggle(rule)} + /> + + + handleDelete(rule.id)}> + Delete + + + + ))} + + + )} + + + + + + + + Active Policies + + Rate limit policies currently active in the middleware pipeline. These are + registered at application startup via module ConfigureRateLimits() hooks. + + + + {activePolicies.length === 0 ? ( + + No active rate limit policies. + + ) : ( + + + + Name + Type + Target + Limit + + + + {activePolicies.map((policy) => ( + + + {policy.name} + + + + + + + + + {policy.policyType === 'TokenBucket' + ? `${policy.tokenLimit} tokens (${policy.tokensPerPeriod}/period)` + : `${policy.permitLimit} req/${policy.windowSeconds}s`} + + + ))} + + + )} + + + + + + ); +} diff --git a/modules/RateLimiting/src/SimpleModule.RateLimiting/Views/AdminEndpoint.cs b/modules/RateLimiting/src/SimpleModule.RateLimiting/Views/AdminEndpoint.cs new file mode 100644 index 00000000..e9e9190b --- /dev/null +++ b/modules/RateLimiting/src/SimpleModule.RateLimiting/Views/AdminEndpoint.cs @@ -0,0 +1,43 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.Core.Inertia; +using SimpleModule.Core.RateLimiting; +using SimpleModule.RateLimiting.Contracts; + +namespace SimpleModule.RateLimiting.Views; + +[ViewPage("RateLimiting/Admin")] +public class AdminEndpoint : IViewEndpoint +{ + public void Map(IEndpointRouteBuilder app) + { + app.MapGet( + "/", + async (IRateLimitingContracts contracts, IRateLimitPolicyRegistry policyRegistry) => + { + var rules = await contracts.GetAllRulesAsync(); + var activePolicies = policyRegistry.GetPolicies(); + return Inertia.Render( + "RateLimiting/Admin", + new + { + rules, + activePolicies = activePolicies.Select(p => new + { + p.Name, + PolicyType = p.PolicyType.ToString(), + Target = p.Target.ToString(), + p.PermitLimit, + WindowSeconds = (int)p.Window.TotalSeconds, + p.TokenLimit, + p.TokensPerPeriod, + }), + } + ); + } + ) + .RequirePermission(RateLimitingPermissions.View); + } +} diff --git a/modules/RateLimiting/src/SimpleModule.RateLimiting/package.json b/modules/RateLimiting/src/SimpleModule.RateLimiting/package.json new file mode 100644 index 00000000..1b93ada1 --- /dev/null +++ b/modules/RateLimiting/src/SimpleModule.RateLimiting/package.json @@ -0,0 +1,14 @@ +{ + "private": true, + "name": "@simplemodule/ratelimiting", + "version": "0.0.0", + "scripts": { + "build": "cross-env VITE_MODE=prod vite build --configLoader runner", + "build:dev": "cross-env VITE_MODE=dev vite build --configLoader runner", + "watch": "cross-env VITE_MODE=dev vite build --configLoader runner --watch" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + } +} diff --git a/modules/RateLimiting/src/SimpleModule.RateLimiting/types.ts b/modules/RateLimiting/src/SimpleModule.RateLimiting/types.ts new file mode 100644 index 00000000..7dce28da --- /dev/null +++ b/modules/RateLimiting/src/SimpleModule.RateLimiting/types.ts @@ -0,0 +1,48 @@ +// Auto-generated from [Dto] types — do not edit +export interface CreateRateLimitRuleRequest { + policyName: string; + policyType: any; + target: any; + permitLimit: number; + windowSeconds: number; + segmentsPerWindow: number; + tokenLimit: number; + tokensPerPeriod: number; + replenishmentPeriodSeconds: number; + queueLimit: number; + endpointPattern: string; + isEnabled: boolean; +} + +export interface RateLimitRule { + id: number; + policyName: string; + policyType: any; + target: any; + permitLimit: number; + windowSeconds: number; + segmentsPerWindow: number; + tokenLimit: number; + tokensPerPeriod: number; + replenishmentPeriodSeconds: number; + queueLimit: number; + endpointPattern: string; + isEnabled: boolean; + createdAt: string; + updatedAt: string | null; +} + +export interface UpdateRateLimitRuleRequest { + policyType: any; + target: any; + permitLimit: number; + windowSeconds: number; + segmentsPerWindow: number; + tokenLimit: number; + tokensPerPeriod: number; + replenishmentPeriodSeconds: number; + queueLimit: number; + endpointPattern: string; + isEnabled: boolean; +} + diff --git a/modules/RateLimiting/src/SimpleModule.RateLimiting/vite.config.ts b/modules/RateLimiting/src/SimpleModule.RateLimiting/vite.config.ts new file mode 100644 index 00000000..a247db62 --- /dev/null +++ b/modules/RateLimiting/src/SimpleModule.RateLimiting/vite.config.ts @@ -0,0 +1,3 @@ +import { defineModuleConfig } from '@simplemodule/client/module'; + +export default defineModuleConfig(import.meta.dirname); diff --git a/modules/RateLimiting/tests/SimpleModule.RateLimiting.Tests/GlobalUsings.cs b/modules/RateLimiting/tests/SimpleModule.RateLimiting.Tests/GlobalUsings.cs new file mode 100644 index 00000000..c802f448 --- /dev/null +++ b/modules/RateLimiting/tests/SimpleModule.RateLimiting.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/modules/RateLimiting/tests/SimpleModule.RateLimiting.Tests/RateLimitPolicyRegistryTests.cs b/modules/RateLimiting/tests/SimpleModule.RateLimiting.Tests/RateLimitPolicyRegistryTests.cs new file mode 100644 index 00000000..44d76804 --- /dev/null +++ b/modules/RateLimiting/tests/SimpleModule.RateLimiting.Tests/RateLimitPolicyRegistryTests.cs @@ -0,0 +1,114 @@ +using FluentAssertions; +using SimpleModule.Core.RateLimiting; + +namespace SimpleModule.RateLimiting.Tests; + +public class RateLimitPolicyRegistryTests +{ + [Fact] + public void Add_ShouldRegisterPolicy() + { + var registry = new RateLimitPolicyRegistry(); + + registry.Add( + new RateLimitPolicyDefinition + { + Name = "test-policy", + PolicyType = RateLimitPolicyType.FixedWindow, + PermitLimit = 100, + Window = TimeSpan.FromMinutes(1), + } + ); + + registry.GetPolicies().Should().HaveCount(1); + registry.GetPolicies()[0].Name.Should().Be("test-policy"); + } + + [Fact] + public void GetPolicy_ShouldReturnPolicy_WhenExists() + { + var registry = new RateLimitPolicyRegistry(); + registry.Add( + new RateLimitPolicyDefinition + { + Name = "my-policy", + PolicyType = RateLimitPolicyType.SlidingWindow, + PermitLimit = 50, + } + ); + + var policy = registry.GetPolicy("my-policy"); + + policy.Should().NotBeNull(); + policy!.PolicyType.Should().Be(RateLimitPolicyType.SlidingWindow); + policy.PermitLimit.Should().Be(50); + } + + [Fact] + public void GetPolicy_ShouldReturnNull_WhenNotFound() + { + var registry = new RateLimitPolicyRegistry(); + + var policy = registry.GetPolicy("nonexistent"); + + policy.Should().BeNull(); + } + + [Fact] + public void GetPolicy_ShouldBeCaseInsensitive() + { + var registry = new RateLimitPolicyRegistry(); + registry.Add(new RateLimitPolicyDefinition { Name = "Test-Policy" }); + + var policy = registry.GetPolicy("test-policy"); + + policy.Should().NotBeNull(); + } + + [Fact] + public void Add_ShouldSupportChainingMultiplePolicies() + { + var registry = new RateLimitPolicyRegistry(); + + registry + .Add( + new RateLimitPolicyDefinition + { + Name = "fixed", + PolicyType = RateLimitPolicyType.FixedWindow, + } + ) + .Add( + new RateLimitPolicyDefinition + { + Name = "sliding", + PolicyType = RateLimitPolicyType.SlidingWindow, + } + ) + .Add( + new RateLimitPolicyDefinition + { + Name = "token", + PolicyType = RateLimitPolicyType.TokenBucket, + } + ); + + registry.GetPolicies().Should().HaveCount(3); + } + + [Fact] + public void DefaultValues_ShouldBeReasonable() + { + var definition = new RateLimitPolicyDefinition { Name = "defaults" }; + + definition.PolicyType.Should().Be(RateLimitPolicyType.FixedWindow); + definition.Target.Should().Be(RateLimitTarget.Ip); + definition.PermitLimit.Should().Be(60); + definition.Window.Should().Be(TimeSpan.FromMinutes(1)); + definition.SegmentsPerWindow.Should().Be(4); + definition.TokenLimit.Should().Be(100); + definition.TokensPerPeriod.Should().Be(10); + definition.ReplenishmentPeriod.Should().Be(TimeSpan.FromSeconds(10)); + definition.QueueLimit.Should().Be(0); + } +} diff --git a/modules/RateLimiting/tests/SimpleModule.RateLimiting.Tests/RateLimitingEndpointTests.cs b/modules/RateLimiting/tests/SimpleModule.RateLimiting.Tests/RateLimitingEndpointTests.cs new file mode 100644 index 00000000..e2905058 --- /dev/null +++ b/modules/RateLimiting/tests/SimpleModule.RateLimiting.Tests/RateLimitingEndpointTests.cs @@ -0,0 +1,48 @@ +using System.Net; +using System.Security.Claims; +using FluentAssertions; +using SimpleModule.Tests.Shared.Fixtures; + +namespace SimpleModule.RateLimiting.Tests; + +public class RateLimitingEndpointTests : IClassFixture +{ + private readonly SimpleModuleWebApplicationFactory _factory; + + public RateLimitingEndpointTests(SimpleModuleWebApplicationFactory factory) => + _factory = factory; + + [Fact] + public async Task AdminPage_ReturnsOk_ForAuthenticatedAdmin() + { + using var client = _factory.CreateAuthenticatedClient(new Claim(ClaimTypes.Role, "Admin")); + + var response = await client.GetAsync("/rate-limiting"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var content = await response.Content.ReadAsStringAsync(); + content.Should().Contain("RateLimiting/Admin"); + } + + [Fact] + public async Task ActivePoliciesApi_ReturnsOk_ForAuthenticatedAdmin() + { + using var client = _factory.CreateAuthenticatedClient(new Claim(ClaimTypes.Role, "Admin")); + + var response = await client.GetAsync("/api/rate-limiting/active"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var content = await response.Content.ReadAsStringAsync(); + content.Should().Contain("fixed-default"); + } + + [Fact] + public async Task RulesApi_ReturnsOk_ForAuthenticatedAdmin() + { + using var client = _factory.CreateAuthenticatedClient(new Claim(ClaimTypes.Role, "Admin")); + + var response = await client.GetAsync("/api/rate-limiting"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } +} diff --git a/modules/RateLimiting/tests/SimpleModule.RateLimiting.Tests/RateLimitingModuleTests.cs b/modules/RateLimiting/tests/SimpleModule.RateLimiting.Tests/RateLimitingModuleTests.cs new file mode 100644 index 00000000..33c83164 --- /dev/null +++ b/modules/RateLimiting/tests/SimpleModule.RateLimiting.Tests/RateLimitingModuleTests.cs @@ -0,0 +1,68 @@ +using FluentAssertions; +using SimpleModule.Core.RateLimiting; + +namespace SimpleModule.RateLimiting.Tests; + +public class RateLimitingModuleTests +{ + [Fact] + public void ConfigureRateLimits_ShouldRegisterBuiltInPolicies() + { + var module = new RateLimitingModule(); + var registry = new RateLimitPolicyRegistry(); + + module.ConfigureRateLimits(registry); + + var policies = registry.GetPolicies(); + policies.Should().HaveCount(4); + policies.Should().Contain(p => p.Name == "fixed-default"); + policies.Should().Contain(p => p.Name == "sliding-strict"); + policies.Should().Contain(p => p.Name == "token-bucket"); + policies.Should().Contain(p => p.Name == "auth-strict"); + } + + [Fact] + public void ConfigureRateLimits_FixedDefault_ShouldHaveCorrectSettings() + { + var module = new RateLimitingModule(); + var registry = new RateLimitPolicyRegistry(); + + module.ConfigureRateLimits(registry); + + var policy = registry.GetPolicy("fixed-default"); + policy.Should().NotBeNull(); + policy!.PolicyType.Should().Be(RateLimitPolicyType.FixedWindow); + policy.Target.Should().Be(RateLimitTarget.Ip); + policy.PermitLimit.Should().Be(60); + policy.Window.Should().Be(TimeSpan.FromMinutes(1)); + } + + [Fact] + public void ConfigureRateLimits_AuthStrict_ShouldHaveLowLimit() + { + var module = new RateLimitingModule(); + var registry = new RateLimitPolicyRegistry(); + + module.ConfigureRateLimits(registry); + + var policy = registry.GetPolicy("auth-strict"); + policy.Should().NotBeNull(); + policy!.PermitLimit.Should().Be(10); + } + + [Fact] + public void ConfigureRateLimits_TokenBucket_ShouldHaveCorrectSettings() + { + var module = new RateLimitingModule(); + var registry = new RateLimitPolicyRegistry(); + + module.ConfigureRateLimits(registry); + + var policy = registry.GetPolicy("token-bucket"); + policy.Should().NotBeNull(); + policy!.PolicyType.Should().Be(RateLimitPolicyType.TokenBucket); + policy.TokenLimit.Should().Be(100); + policy.TokensPerPeriod.Should().Be(10); + policy.ReplenishmentPeriod.Should().Be(TimeSpan.FromSeconds(10)); + } +} diff --git a/modules/RateLimiting/tests/SimpleModule.RateLimiting.Tests/RateLimitingServiceTests.cs b/modules/RateLimiting/tests/SimpleModule.RateLimiting.Tests/RateLimitingServiceTests.cs new file mode 100644 index 00000000..883a9d8d --- /dev/null +++ b/modules/RateLimiting/tests/SimpleModule.RateLimiting.Tests/RateLimitingServiceTests.cs @@ -0,0 +1,146 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using SimpleModule.Core.RateLimiting; +using SimpleModule.Database; +using SimpleModule.RateLimiting.Contracts; + +namespace SimpleModule.RateLimiting.Tests; + +public sealed class RateLimitingServiceTests : IDisposable +{ + private readonly RateLimitingDbContext _db; + private readonly RateLimitingService _service; + + public RateLimitingServiceTests() + { + var dbOptions = new DbContextOptionsBuilder() + .UseSqlite("Data Source=:memory:") + .Options; + var databaseOptions = Options.Create( + new DatabaseOptions + { + ModuleConnections = new Dictionary + { + ["RateLimiting"] = "Data Source=:memory:", + }, + } + ); + _db = new RateLimitingDbContext(dbOptions, databaseOptions); + _db.Database.OpenConnection(); + _db.Database.EnsureCreated(); + _service = new RateLimitingService(_db, NullLogger.Instance); + } + + [Fact] + public async Task CreateRuleAsync_ShouldPersistRule() + { + var request = new CreateRateLimitRuleRequest + { + PolicyName = "test-fixed", + PolicyType = RateLimitPolicyType.FixedWindow, + Target = RateLimitTarget.Ip, + PermitLimit = 100, + WindowSeconds = 60, + }; + + var result = await _service.CreateRuleAsync(request); + + result.PolicyName.Should().Be("test-fixed"); + result.PermitLimit.Should().Be(100); + result.Id.Value.Should().BeGreaterThan(0); + } + + [Fact] + public async Task GetAllRulesAsync_ShouldReturnAllRules() + { + await _service.CreateRuleAsync(new CreateRateLimitRuleRequest { PolicyName = "rule-1" }); + await _service.CreateRuleAsync(new CreateRateLimitRuleRequest { PolicyName = "rule-2" }); + + var rules = await _service.GetAllRulesAsync(); + + rules.Should().HaveCount(2); + } + + [Fact] + public async Task GetRuleByIdAsync_ShouldReturnRule_WhenExists() + { + var created = await _service.CreateRuleAsync( + new CreateRateLimitRuleRequest { PolicyName = "find-me" } + ); + + var found = await _service.GetRuleByIdAsync(created.Id); + + found.Should().NotBeNull(); + found!.PolicyName.Should().Be("find-me"); + } + + [Fact] + public async Task GetRuleByIdAsync_ShouldReturnNull_WhenNotFound() + { + var found = await _service.GetRuleByIdAsync(RateLimitRuleId.From(999)); + + found.Should().BeNull(); + } + + [Fact] + public async Task UpdateRuleAsync_ShouldModifyRule() + { + var created = await _service.CreateRuleAsync( + new CreateRateLimitRuleRequest { PolicyName = "update-me", PermitLimit = 60 } + ); + + var updated = await _service.UpdateRuleAsync( + created.Id, + new UpdateRateLimitRuleRequest + { + PermitLimit = 200, + PolicyType = RateLimitPolicyType.SlidingWindow, + Target = RateLimitTarget.User, + } + ); + + updated.PermitLimit.Should().Be(200); + updated.PolicyType.Should().Be(RateLimitPolicyType.SlidingWindow); + updated.Target.Should().Be(RateLimitTarget.User); + updated.UpdatedAt.Should().NotBeNull(); + } + + [Fact] + public async Task DeleteRuleAsync_ShouldRemoveRule() + { + var created = await _service.CreateRuleAsync( + new CreateRateLimitRuleRequest { PolicyName = "delete-me" } + ); + + await _service.DeleteRuleAsync(created.Id); + + var found = await _service.GetRuleByIdAsync(created.Id); + found.Should().BeNull(); + } + + [Fact] + public async Task DeleteRuleAsync_ShouldThrow_WhenNotFound() + { + var act = () => _service.DeleteRuleAsync(RateLimitRuleId.From(999)); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task UpdateRuleAsync_ShouldThrow_WhenNotFound() + { + var act = () => + _service.UpdateRuleAsync(RateLimitRuleId.From(999), new UpdateRateLimitRuleRequest()); + + await act.Should().ThrowAsync(); + } + + public void Dispose() + { + _db.Database.CloseConnection(); + _db.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/modules/RateLimiting/tests/SimpleModule.RateLimiting.Tests/SimpleModule.RateLimiting.Tests.csproj b/modules/RateLimiting/tests/SimpleModule.RateLimiting.Tests/SimpleModule.RateLimiting.Tests.csproj new file mode 100644 index 00000000..37878918 --- /dev/null +++ b/modules/RateLimiting/tests/SimpleModule.RateLimiting.Tests/SimpleModule.RateLimiting.Tests.csproj @@ -0,0 +1,22 @@ + + + net10.0 + false + Exe + + + + + + + + + + + + + + + + + diff --git a/package-lock.json b/package-lock.json index aa64ad8f..9c39925f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -143,6 +143,14 @@ "react-dom": "^19.0.0" } }, + "modules/RateLimiting/src/SimpleModule.RateLimiting": { + "name": "@simplemodule/ratelimiting", + "version": "0.0.0", + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, "modules/Settings/src/SimpleModule.Settings": { "name": "@simplemodule/settings", "version": "0.0.0", @@ -4112,6 +4120,10 @@ "resolved": "modules/Products/src/SimpleModule.Products", "link": true }, + "node_modules/@simplemodule/ratelimiting": { + "resolved": "modules/RateLimiting/src/SimpleModule.RateLimiting", + "link": true + }, "node_modules/@simplemodule/settings": { "resolved": "modules/Settings/src/SimpleModule.Settings", "link": true diff --git a/template/SimpleModule.Host/Migrations/20260403145434_AddRateLimitingModule.Designer.cs b/template/SimpleModule.Host/Migrations/20260403145434_AddRateLimitingModule.Designer.cs new file mode 100644 index 00000000..ba45e522 --- /dev/null +++ b/template/SimpleModule.Host/Migrations/20260403145434_AddRateLimitingModule.Designer.cs @@ -0,0 +1,1830 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using SimpleModule.Host; + +#nullable disable + +namespace SimpleModule.Host.Migrations +{ + [DbContext(typeof(HostDbContext))] + [Migration("20260403145434_AddRateLimitingModule")] + partial class AddRateLimitingModule + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.3"); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("Users_AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Users_AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("Users_AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("Users_AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("Users_AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ApplicationType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ClientId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ClientSecret") + .HasColumnType("TEXT"); + + b.Property("ClientType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ConsentType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .HasColumnType("TEXT"); + + b.Property("DisplayNames") + .HasColumnType("TEXT"); + + b.Property("JsonWebKeySet") + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("TEXT"); + + b.Property("PostLogoutRedirectUris") + .HasColumnType("TEXT"); + + b.Property("Properties") + .HasColumnType("TEXT"); + + b.Property("RedirectUris") + .HasColumnType("TEXT"); + + b.Property("Requirements") + .HasColumnType("TEXT"); + + b.Property("Settings") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ClientId") + .IsUnique(); + + b.ToTable("OpenIddict_OpenIddictApplications", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ApplicationId") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Properties") + .HasColumnType("TEXT"); + + b.Property("Scopes") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("TEXT"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddict_OpenIddictAuthorizations", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreScope", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("Descriptions") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .HasColumnType("TEXT"); + + b.Property("DisplayNames") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Properties") + .HasColumnType("TEXT"); + + b.Property("Resources") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("OpenIddict_OpenIddictScopes", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ApplicationId") + .HasColumnType("TEXT"); + + b.Property("AuthorizationId") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("Payload") + .HasColumnType("TEXT"); + + b.Property("Properties") + .HasColumnType("TEXT"); + + b.Property("RedemptionDate") + .HasColumnType("TEXT"); + + b.Property("ReferenceId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("TEXT"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AuthorizationId"); + + b.HasIndex("ReferenceId") + .IsUnique(); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddict_OpenIddictTokens", (string)null); + }); + + modelBuilder.Entity("SimpleModule.Agents.Sessions.AgentMessage", b => + { + b.Property("Id") + .HasMaxLength(36) + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("SessionId") + .IsRequired() + .HasMaxLength(36) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("TokenCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SessionId"); + + b.HasIndex("SessionId", "Timestamp"); + + b.ToTable("Agents_Messages", (string)null); + }); + + modelBuilder.Entity("SimpleModule.Agents.Sessions.AgentSession", b => + { + b.Property("Id") + .HasMaxLength(36) + .HasColumnType("TEXT"); + + b.Property("AgentName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("LastMessageAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AgentName"); + + b.HasIndex("UserId"); + + b.ToTable("Agents_Sessions", (string)null); + }); + + modelBuilder.Entity("SimpleModule.AuditLogs.Contracts.AuditEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Action") + .HasColumnType("INTEGER"); + + b.Property("Changes") + .HasColumnType("TEXT"); + + b.Property("CorrelationId") + .HasColumnType("TEXT"); + + b.Property("DurationMs") + .HasColumnType("INTEGER"); + + b.Property("EntityId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EntityType") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("HttpMethod") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("TEXT"); + + b.Property("Metadata") + .HasColumnType("TEXT"); + + b.Property("Module") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Path") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("QueryString") + .HasColumnType("TEXT"); + + b.Property("RequestBody") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("StatusCode") + .HasColumnType("INTEGER"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("UserAgent") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Action"); + + b.HasIndex("CorrelationId"); + + b.HasIndex("Path"); + + b.HasIndex("Source"); + + b.HasIndex("StatusCode"); + + b.HasIndex("Timestamp") + .IsDescending(); + + b.HasIndex("EntityType", "EntityId"); + + b.HasIndex("Module", "Timestamp") + .IsDescending(false, true); + + b.HasIndex("UserId", "Timestamp") + .IsDescending(false, true); + + b.ToTable("AuditLogs_AuditEntries", (string)null); + }); + + modelBuilder.Entity("SimpleModule.BackgroundJobs.Entities.JobProgress", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("JobTypeName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Logs") + .HasColumnType("TEXT"); + + b.Property("ModuleName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ProgressMessage") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("ProgressPercentage") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ModuleName"); + + b.ToTable("BackgroundJobs_JobProgress", (string)null); + }); + + modelBuilder.Entity("SimpleModule.Email.Contracts.EmailMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bcc") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Body") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Cc") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("IsHtml") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ReplyTo") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("RetryCount") + .HasColumnType("INTEGER"); + + b.Property("SentAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("TemplateSlug") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("To") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Status"); + + b.ToTable("Email_EmailMessages", (string)null); + }); + + modelBuilder.Entity("SimpleModule.Email.Contracts.EmailTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DefaultReplyTo") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("IsHtml") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Email_EmailTemplates", (string)null); + }); + + modelBuilder.Entity("SimpleModule.FeatureFlags.Entities.FeatureFlagEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IsDeprecated") + .HasColumnType("INTEGER"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("FeatureFlags_FeatureFlags", (string)null); + }); + + modelBuilder.Entity("SimpleModule.FeatureFlags.Entities.FeatureFlagOverrideEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("FlagName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("OverrideType") + .HasColumnType("INTEGER"); + + b.Property("OverrideValue") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("FlagName", "OverrideType", "OverrideValue") + .IsUnique(); + + b.ToTable("FeatureFlags_FeatureFlagOverrides", (string)null); + }); + + modelBuilder.Entity("SimpleModule.FileStorage.Contracts.StoredFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Folder") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("Size") + .HasColumnType("INTEGER"); + + b.Property("StoragePath") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Folder"); + + b.HasIndex("Folder", "FileName") + .IsUnique(); + + b.ToTable("FileStorage_StoredFiles", (string)null); + }); + + modelBuilder.Entity("SimpleModule.Orders.Contracts.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Total") + .HasColumnType("decimal(18,2)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Orders_Orders", (string)null); + }); + + modelBuilder.Entity("SimpleModule.Orders.Contracts.OrderItem", b => + { + b.Property("OrderId") + .HasColumnType("INTEGER"); + + b.Property("ProductId") + .HasColumnType("INTEGER"); + + b.Property("Quantity") + .HasColumnType("INTEGER"); + + b.HasKey("OrderId", "ProductId"); + + b.ToTable("Orders_OrderItems", (string)null); + }); + + modelBuilder.Entity("SimpleModule.PageBuilder.Contracts.Page", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("DraftContent") + .HasColumnType("TEXT"); + + b.Property("IsPublished") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("MetaDescription") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("MetaKeywords") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("OgImage") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Order") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeletedAt"); + + b.HasIndex("IsPublished"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("PageBuilder_Pages", (string)null); + }); + + modelBuilder.Entity("SimpleModule.PageBuilder.Contracts.PageTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("PageId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("PageId"); + + b.ToTable("PageBuilder_Tags", (string)null); + }); + + modelBuilder.Entity("SimpleModule.PageBuilder.Contracts.PageTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("PageBuilder_Templates", (string)null); + }); + + modelBuilder.Entity("SimpleModule.Permissions.Entities.RolePermission", b => + { + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.Property("Permission") + .HasColumnType("TEXT"); + + b.HasKey("RoleId", "Permission"); + + b.ToTable("Permissions_RolePermissions", (string)null); + }); + + modelBuilder.Entity("SimpleModule.Permissions.Entities.UserPermission", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Permission") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "Permission"); + + b.ToTable("Permissions_UserPermissions", (string)null); + }); + + modelBuilder.Entity("SimpleModule.Products.Contracts.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("decimal(18,2)"); + + b.HasKey("Id"); + + b.ToTable("Products_Products", (string)null); + + b.HasData( + new + { + Id = 1, + Name = "Fantastic Rubber Shoes", + Price = 991.68m + }, + new + { + Id = 2, + Name = "Fantastic Rubber Bacon", + Price = 446.22m + }, + new + { + Id = 3, + Name = "Fantastic Concrete Bike", + Price = 660.12m + }, + new + { + Id = 4, + Name = "Handcrafted Concrete Keyboard", + Price = 633.67m + }, + new + { + Id = 5, + Name = "Intelligent Frozen Mouse", + Price = 674.30m + }, + new + { + Id = 6, + Name = "Sleek Soft Hat", + Price = 851.63m + }, + new + { + Id = 7, + Name = "Practical Fresh Bike", + Price = 417.48m + }, + new + { + Id = 8, + Name = "Handmade Steel Ball", + Price = 975.56m + }, + new + { + Id = 9, + Name = "Ergonomic Fresh Pants", + Price = 928.09m + }, + new + { + Id = 10, + Name = "Licensed Steel Sausages", + Price = 592.60m + }); + }); + + modelBuilder.Entity("SimpleModule.Rag.StructuredRag.Data.CachedStructuredKnowledge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CollectionName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DocumentHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("HitCount") + .HasColumnType("INTEGER"); + + b.Property("SourceTitle") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("StructureType") + .HasColumnType("INTEGER"); + + b.Property("StructuredContent") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("CollectionName", "DocumentHash", "StructureType") + .IsUnique(); + + b.ToTable("Rag_CachedStructuredKnowledge", (string)null); + }); + + modelBuilder.Entity("SimpleModule.RateLimiting.Contracts.RateLimitRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EndpointPattern") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("PermitLimit") + .HasColumnType("INTEGER"); + + b.Property("PolicyName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("PolicyType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("QueueLimit") + .HasColumnType("INTEGER"); + + b.Property("ReplenishmentPeriodSeconds") + .HasColumnType("INTEGER"); + + b.Property("SegmentsPerWindow") + .HasColumnType("INTEGER"); + + b.Property("Target") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("TokenLimit") + .HasColumnType("INTEGER"); + + b.Property("TokensPerPeriod") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WindowSeconds") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PolicyName") + .IsUnique(); + + b.ToTable("RateLimiting_Rules", (string)null); + }); + + modelBuilder.Entity("SimpleModule.Settings.Entities.PublicMenuItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CssClass") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Icon") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("IsHomePage") + .HasColumnType("INTEGER"); + + b.Property
+ No rate limit rules configured yet. +
+ No active rate limit policies. +