Substratum 2.24.0

dotnet add package Substratum --version 2.24.0
                    
NuGet\Install-Package Substratum -Version 2.24.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Substratum" Version="2.24.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Substratum" Version="2.24.0" />
                    
Directory.Packages.props
<PackageReference Include="Substratum" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Substratum --version 2.24.0
                    
#r "nuget: Substratum, 2.24.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Substratum@2.24.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Substratum&version=2.24.0
                    
Install as a Cake Addin
#tool nuget:?package=Substratum&version=2.24.0
                    
Install as a Cake Tool

Substratum

Substratum

The batteries-included backend framework for ASP.NET Core. Ship a production-grade API in minutes — not weeks.

Substratum Generator Tools MCP .NET 10 MIT License


Substratum is an opinionated, production-grade application framework for ASP.NET Core. Authentication, authorization, database, caching, logging, OpenAPI docs, cloud storage, push notifications, real-time events, background jobs, rate limiting, and more — all pre-wired and configured via appsettings.json.

Write your business logic. Substratum handles the rest.

// Your entire Program.cs
return await SubstratumApp.RunAsync(args);

That's it. One line. Everything else — endpoints, validators, event handlers, DbContexts, permissions, auth, OpenAPI — is discovered and wired automatically.


Why Substratum?

Without Substratum With Substratum
New project Hours of boilerplate dotnet-sub new webapp MyApp — done
Auth (JWT + refresh + cookies + API keys) 500+ lines of wiring Toggle flags in appsettings.json
Permissions Hand-rolled string constants Type-safe, auto-discovered, EF-stored as JSON
Real-time push Install SignalR, wire hubs, auth, Redis backplane Enabled: true — done
Endpoint routing Repeat .MapPost(...) dozens of times Inherit Endpoint<TReq, TRes>, it maps itself
Validation Manual pipeline Inherit Validator<T> — auto-runs, auto-returns 422
OpenAPI Decorate everything Generated from your endpoint signatures
Pagination, Result pattern, soft deletes, audit logs Roll your own Built in

Built on top of — not replacing — ASP.NET Core. Every endpoint is a real Minimal API route. No reflection at runtime. Source-generated. AOT-friendly.


Performance

Substratum's opinionation costs ~0%. Full HTTP round-trips through Kestrel, measured across five scenarios against Microsoft.AspNetCore.App Minimal APIs (baseline) and FastEndpoints.

Scenario Minimal API Substratum FastEndpoints
GET list/v1/forecasts 51.06 μs — 5.10 KB 51.30 μs (1.00×) — 5.26 KB 51.91 μs (1.02×) — 6.40 KB
GET by ID/v1/forecasts/42 49.98 μs — 4.26 KB 49.60 μs (1.01× faster) — 4.43 KB 52.66 μs (1.05×) — 6.09 KB
POST validated/v1/orders 60.08 μs — 18.02 KB 60.09 μs (1.00×) — 17.88 KB 59.23 μs (1.01× faster) — 10.50 KB
POST validation failure 62.30 μs — 23.82 KB 62.55 μs (1.00×) — 23.85 KB 69.04 μs (1.11×) — 18.89 KB
Route miss (404) 47.65 μs — 4.01 KB 47.90 μs (1.01×) — 4.01 KB 48.22 μs (1.01×) — 4.47 KB

Takeaways:

  • Substratum is within 1% of raw Minimal API on every scenario — 1% faster on GET-by-ID, 0–1% slower on the others.
  • Allocation is effectively identical to Minimal API (~0.1–0.2 KB difference per request).
  • On the validation-failure path Substratum is ~10% faster than FastEndpoints; on GET-by-ID, ~6% faster.
  • The Endpoint<TReq, TRes> class-based abstraction pays no runtime cost — it's all source-generated.

<details> <summary>Benchmark setup</summary>

  • BenchmarkDotNet v0.15.8, .NET 10.0.0, Apple M4 Pro (12 cores), macOS 26.1
  • 5 warmup + 20 iteration runs, in-process Kestrel, HttpClient round-trip
  • Reproduce: cd benchmarks/Substratum.Benchmarks && dotnet run -c Release -- --filter '*Throughput*'
  • Full suite: 8 benchmark classes covering throughput, concurrent load, serialization, middleware pipeline, cold-start, minimal-overhead — run with no --filter for the interactive menu.

</details>


Table of Contents


Install

The packages

Package What it is Install
Substratum The runtime library <PackageReference Include="Substratum" Version="2.19.0" />
Substratum.Generator Source generators (zero reflection, AOT-friendly) <PackageReference Include="Substratum.Generator" Version="2.19.0" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
Substratum.Tools Global CLI (dotnet-sub) — scaffolding, migrations dotnet tool install -g Substratum.Tools
Substratum.Mcp MCP server (dotnet-sub-mcp) — lets AI assistants like Claude scaffold, analyze, and migrate your project dotnet tool install -g Substratum.Mcp

Requirements

  • .NET 10 SDK or later
  • A database (PostgreSQL, SQL Server, SQLite, or MySQL)
  • Optional: Redis (for distributed cache, Live Events clustering, refresh tokens)

60-Second Quick Start

1. Scaffold a project

dotnet tool install -g Substratum.Tools
dotnet-sub new webapp MyApp
cd MyApp

You get a complete project: Program.cs, appsettings.json, AppDbContext, AppPermissions, DocGroups, sample endpoint, ready to run.

2. Run it

dotnet run
  • API: http://localhost:5000
  • Docs UI: http://localhost:5000/scalar/v1
  • Health: http://localhost:5000/healthz

3. Create your first endpoint

dotnet-sub new endpoint --group Users --name ListUsers --method Get --route /users

That one command creates the endpoint, its request DTO, response DTO, validator, and OpenAPI summary — already wired, already routed.

4. Write the logic

public class ListUsersEndpoint : Endpoint<ListUsersRequest, Result<List<UserDto>>>
{
    private readonly AppDbContext _db;

    public ListUsersEndpoint(AppDbContext db) => _db = db;

    public override void Configure(EndpointRouteConfigure route)
    {
        route.Version(1);
        route.Get("/users");
        route.AllowAnonymous();
    }

    public override async Task<Result<List<UserDto>>> ExecuteAsync(
        ListUsersRequest req, CancellationToken ct)
    {
        var users = await _db.Users.AsNoTracking()
            .Select(u => new UserDto { Id = u.Id, Name = u.FullName })
            .ToListAsync(ct);

        return Success("Users retrieved", users);
    }
}

Run dotnet run. Endpoint is live at GET /v1/users. You didn't touch Program.cs, didn't register anything, didn't configure a route table.

Adding services manually

If you need to register extra services:

return await SubstratumApp.RunAsync(args, options =>
{
    options.Services.AddSingleton<IMyService, MyService>();
    options.Services.AddHttpClient<IPaymentsClient, PaymentsClient>();
});

Endpoints

Every endpoint is a class. Substratum provides four base classes:

Base class Use for
Endpoint<TRequest, TResponse> Standard JSON request/response
Endpoint<TRequest> No response body (downloads, redirects, custom writes)
StreamEndpoint<TRequest> Server-Sent Events (untyped items)
StreamEndpoint<TRequest, TResponse> Server-Sent Events (typed items)

Anatomy of an endpoint

Each endpoint lives in its own folder:

Features/Users/CreateUser/
  CreateUserEndpoint.cs           // Handler
  CreateUserRequest.cs            // Input
  CreateUserResponse.cs           // Output
  CreateUserRequestValidator.cs   // Validation rules
  CreateUserSummary.cs            // OpenAPI description

All five are generated by one command:

dotnet-sub new endpoint --group Users --name CreateUser --method Post --route /users --permission Users_Create

Route configuration (fluent)

Every endpoint configures its route in the Configure method:

public override void Configure(EndpointRouteConfigure route)
{
    route.Version(1);                                       // /v1 prefix
    route.Post("/orders");                                  // HTTP verb + path
    route.PermissionsAll(AppPermissions.Orders_Create);     // Require ALL listed
    route.PermissionsAny(                                   // OR require ANY of
        AppPermissions.Orders_Create,
        AppPermissions.Orders_Admin);
    route.AllowAnonymous();                                 // Skip auth
    route.AuthenticationSchemes("Bearer");                  // Pin a scheme
    route.DocGroup(DocGroups.AdminApi);                     // Assign to docs
    route.Tags("Orders");                                   // OpenAPI tag
    route.AllowFileUploads();                               // Enable multipart
    route.AllowFormData();                                  // Enable form binding
    route.PreProcessor<AuditPreProcessor>();                // Run before handler
    route.PostProcessor<LoggingPostProcessor>();            // Run after handler
    route.Options(o => o.RequireRateLimiting("strict"));    // Raw ASP.NET options
}

Full example

public class CreateUserEndpoint : Endpoint<CreateUserRequest, Result<CreateUserResponse>>
{
    private readonly AppDbContext _db;
    private readonly IStringLocalizer<SharedResource> _t;

    public CreateUserEndpoint(AppDbContext db, IStringLocalizer<SharedResource> t)
    {
        _db = db;
        _t = t;
    }

    public override void Configure(EndpointRouteConfigure route)
    {
        route.Version(1);
        route.Post("/users");
        route.PermissionsAll(AppPermissions.Users_Create);
    }

    public override async Task<Result<CreateUserResponse>> ExecuteAsync(
        CreateUserRequest req, CancellationToken ct)
    {
        if (await _db.Users.AnyAsync(u => u.Username == req.Username, ct))
            return Failure<CreateUserResponse>(409, _t["UsernameAlreadyExists"]);

        var user = new User
        {
            Id = Guid.CreateVersion7(),
            FullName = req.FullName,
            Username = req.Username,
            RoleId = req.RoleId,
        };

        _db.Users.Add(user);
        await _db.SaveChangesAsync(ct);

        return Success(_t["UserCreated"], new CreateUserResponse { Id = user.Id });
    }
}

Void endpoints (downloads, redirects)

public class DownloadFileEndpoint : Endpoint<DownloadFileRequest>
{
    public override void Configure(EndpointRouteConfigure route)
    {
        route.Version(1);
        route.Get("/files/{id}/download");
    }

    public override async Task ExecuteAsync(DownloadFileRequest req, CancellationToken ct)
    {
        var stream = await _storage.DownloadAsync($"files/{req.Id}", ct);
        HttpContext.Response.ContentType = "application/octet-stream";
        await stream.CopyToAsync(HttpContext.Response.Body, ct);
    }
}

Pre/Post processors

Cross-cutting logic around your handler:

public class AuditPreProcessor : IPreProcessor<CreateOrderRequest>
{
    public Task ProcessAsync(CreateOrderRequest req, HttpContext ctx, CancellationToken ct)
    {
        // before the handler runs — log, mutate, short-circuit
        return Task.CompletedTask;
    }
}

public class LoggingPostProcessor : IPostProcessor<CreateOrderRequest, Result<CreateOrderResponse>>
{
    public Task ProcessAsync(CreateOrderRequest req, Result<CreateOrderResponse>? res,
        HttpContext ctx, Exception? exception, CancellationToken ct)
    {
        // after the handler — log result, swallow errors, measure
        return Task.CompletedTask;
    }
}

Validation

Extend Validator<T> (a FluentValidation validator) — auto-discovered, auto-wired, auto-run before your handler:

public class CreateUserRequestValidator : Validator<CreateUserRequest>
{
    public CreateUserRequestValidator(AppDbContext db)
    {
        RuleFor(x => x.FullName).NotEmpty().MaximumLength(200);
        RuleFor(x => x.Username)
            .NotEmpty()
            .MustAsync(async (username, ct) =>
                !await db.Users.AnyAsync(u => u.Username == username, ct))
            .WithMessage("Username already exists");
        RuleFor(x => x.Password).NotEmpty().MinimumLength(8);
    }
}
  • Failures return HTTP 422 with RFC 9457 Problem Details.
  • Constructor injection works (services, DbContext, anything).
  • No registration code. Ever.

The Result Pattern

Every endpoint returns Result<T> — a uniform success/error envelope:

return Success("Users retrieved", users);                            // 200 OK
return Failure<UserDto>(404, "User not found");                      // 404
return Failure<UserDto>(400, "Bad input", ["Email required"]);       // 400 + errors

Response JSON:

{
  "code": 0,
  "message": "Users retrieved",
  "data": [ ... ],
  "errors": null
}

code: 0 = success, code: 1 = failure.

No payload? Use Unit:

public class DeleteUserEndpoint : Endpoint<DeleteUserRequest, Result<Unit>>
{
    public override async Task<Result<Unit>> ExecuteAsync(DeleteUserRequest req, CancellationToken ct)
    {
        // ...
        return Success("Deleted", new Unit());
    }
}

Pagination

Built-in, works directly with EF Core IQueryable:

// Entity in, entity out
var page = await PaginatedResult<User>.CreateAsync(
    _db.Users.OrderBy(u => u.FullName),
    req.PageNumber, req.PageSize, ct);

// Entity in, DTO out (projection runs in SQL)
var page = await PaginatedResult<UserDto>.CreateAsync(
    _db.Users.OrderBy(u => u.FullName),
    u => new UserDto { Id = u.Id, Name = u.FullName },
    req.PageNumber, req.PageSize, ct);

Includes PageNumber, TotalPages, TotalCount, HasPreviousPage, HasNextPage, Items.


Entities

Extend BaseEntity<T>:

public sealed class User : BaseEntity<Guid>
{
    public required string FullName { get; set; }
    public string? Username { get; set; }
    public required Guid RoleId { get; set; }

    public Role Role { get; private set; } = null!;
    public ICollection<Ticket> Tickets { get; private set; } = new HashSet<Ticket>();
}

You get:

Property Behavior
Id Primary key (typed — Guid, int, long, etc.)
CreatedAt Set on insert, automatically
UpdatedAt Set on save, automatically
IsDeleted Soft delete flag
DeletedAt Auto-set when IsDeleted = true; cleared when false

Soft delete:

user.IsDeleted = true;
await _db.SaveChangesAsync(ct);   // DeletedAt set automatically

Scaffold a new one:

dotnet-sub new entity --name Product

Database (Entity Framework)

Configure a provider

{
  "EntityFramework": {
    "Default": {
      "Provider": "Npgsql",
      "ConnectionString": "Host=localhost;Database=mydb;Username=postgres;Password=password",
      "CommandTimeoutSeconds": 30,
      "EnableSeeding": true,
      "Logging": {
        "EnableDetailedErrors": true,
        "EnableSensitiveDataLogging": false
      },
      "RetryPolicy": {
        "Enabled": true,
        "Options": { "MaxRetryCount": 3, "MaxRetryDelaySeconds": 5 }
      },
      "SecondLevelCache": {
        "Enabled": true,
        "Options": { "KeyPrefix": "EF_", "Provider": "Memory" }
      }
    }
  }
}

Supported providers: Npgsql (PostgreSQL), SqlServer, Sqlite, MySql.

Every provider automatically gets snake_case naming, check constraints, and configurable retry policies + second-level caching.

Define your DbContext

public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
    public DbSet<User> Users => Set<User>();
    public DbSet<Role> Roles => Set<Role>();

    protected override void OnModelCreating(ModelBuilder mb)
    {
        base.OnModelCreating(mb);
        mb.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
    }
}

Auto-discovered. No AddDbContext call needed.

Multiple DbContexts

Tag each one:

[DbContextName("Default")]
public class AppDbContext : DbContext { /* ... */ }

[DbContextName("Analytics")]
public class AnalyticsDbContext : DbContext { /* ... */ }
{
  "EntityFramework": {
    "Default":   { "Provider": "Npgsql",    "ConnectionString": "..." },
    "Analytics": { "Provider": "SqlServer", "ConnectionString": "..." }
  }
}

Second-level caching (opt-in per query)

var perms = await _db.Users.AsNoTracking()
    .Where(u => u.Id == userId)
    .Select(u => u.Role.Permissions)
    .Cacheable()
    .FirstOrDefaultAsync(ct);

Providers: Memory or Redis.

Seeding

With EnableSeeding: true, implement IDbContextInitializer<T>:

public class AppDbContextInitializer : IDbContextInitializer<AppDbContext>
{
    public async Task SeedAsync(AppDbContext db, CancellationToken ct)
    {
        if (!await db.Roles.AnyAsync(ct))
        {
            db.Roles.Add(new Role
            {
                Id = Guid.CreateVersion7(),
                Name = "Admin",
                Permissions = AppPermissions.Definitions().ToArray(),
            });
            await db.SaveChangesAsync(ct);
        }
    }
}

Runs automatically on startup.

Migrations

dotnet-sub migrations add InitialCreate
dotnet-sub migrations add AddProducts --context AnalyticsDbContext

dotnet-sub database update
dotnet-sub database update --context AnalyticsDbContext

dotnet-sub database sql                         # export all
dotnet-sub database sql --from V1 --to V2       # between two migrations

Authentication

Four schemes. Each is a toggle in appsettings.json. Enable any combination — they compose into a unified policy.

JWT Bearer

{
  "Authentication": {
    "JwtBearer": {
      "Enabled": true,
      "Options": {
        "SecretKey": "YOUR_SECRET_KEY_AT_LEAST_32_CHARACTERS_LONG",
        "Issuer": "https://myapp.com",
        "Audience": "MyApp",
        "Expiration": "1.00:00:00",
        "RefreshExpiration": "7.00:00:00",
        "ClockSkew": "00:02:00",
        "RequireHttpsMetadata": true
      }
    }
  }
}

Inject IJwtBearer:

// Access token only
var (token, sessionId, expiration) = jwt.CreateToken(user.Id);

// Access + refresh pair (needs IRefreshTokenStore)
var pair = await jwt.CreateTokenPairAsync(user.Id, ct);

// Refresh — old one is invalidated, new pair issued (rotation)
var refreshed = await jwt.RefreshAsync(oldRefreshToken, ct);

Implement IRefreshTokenStore to persist tokens (any backing store — DB, Redis, etc.):

public class RefreshTokenStore : IRefreshTokenStore
{
    public Task StoreAsync(Guid userId, Guid sessionId, string tokenHash,
        DateTimeOffset expiration, CancellationToken ct);
    public Task<RefreshTokenValidationResult?> ValidateAndRevokeAsync(string tokenHash, CancellationToken ct);
    public Task RevokeBySessionAsync(Guid sessionId, CancellationToken ct);
    public Task RevokeAllAsync(Guid userId, CancellationToken ct);
}
{
  "Authentication": {
    "Cookie": {
      "Enabled": true,
      "Options": {
        "CookieName": ".MyApp.Auth",
        "Expiration": "365.00:00:00",
        "SlidingExpiration": true,
        "Secure": true,
        "HttpOnly": true,
        "SameSite": "Lax"
      }
    }
  }
}
await cookieAuth.SignInAsync(HttpContext, user.Id, ct);
await cookieAuth.SignOutAsync(HttpContext, ct);

Basic Auth

{ "Authentication": { "BasicAuthentication": { "Enabled": true, "Options": { "Realm": "MyApp" } } } }

Implement IBasicAuthValidator.

API Key Auth

{ "Authentication": { "ApiKeyAuthentication": { "Enabled": true, "Options": { "Realm": "MyApp", "KeyName": "X-API-KEY" } } } }

Implement IApiKeyValidator.

Multi-app auth

One backend, many clients (web/mobile/admin)? Pass an appId:

var (token, _, _) = jwt.CreateToken(user.Id, appId: "mobile");
// Later: ICurrentUser.AppId == "mobile"

Optionally validate app IDs by implementing IAppResolver.

Current user

Inject ICurrentUser anywhere:

_currentUser.UserId        // Guid?
_currentUser.AppId         // string?
_currentUser.Permissions   // PermissionDefinition[]

Session validation

Revoke sessions server-side? Implement ISessionValidator — runs on every authenticated request.

Password hashing

IPasswordHasher or PasswordHasher.Instance — PBKDF2/HMAC-SHA256, 600,000 iterations:

var hash = hasher.HashPassword("s3cret");
bool ok = hasher.VerifyHashedPassword(hash, "s3cret", out bool needsRehash);

TOTP (2FA)

Inject ITotpProvider:

var secret = totp.GenerateSecret();
var qrUri  = totp.GenerateQrCodeUri(secret, "user@example.com", "MyApp");
bool valid = totp.ValidateCode(secret, "123456");

Permissions

Define them once, type-safe, in a partial class:

public static partial class AppPermissions : IPermissionRegistry
{
    public static readonly PermissionDefinition Users_Create = new(
        code: "users.create",
        name: "Users_Create",
        displayName: "Create User",
        groupCode: "users",
        groupName: "Users",
        groupDisplayName: "User Management"
    );

    public static readonly PermissionDefinition Users_View = new(
        code: "users.view",
        name: "Users_View",
        displayName: "View Users",
        groupCode: "users",
        groupName: "Users",
        groupDisplayName: "User Management"
    );
}

The source generator adds Parse(code), TryParse, Definitions(), and extension methods — no hand-written code.

On endpoints

route.PermissionsAll(AppPermissions.Users_Create);                       // AND
route.PermissionsAny(AppPermissions.Users_View, AppPermissions.Users_ViewOwn);  // OR

At runtime

if (_currentUser.Permissions.HasPermission(AppPermissions.Tickets_View)) { /* ... */ }

if (!_currentUser.Permissions.HasAnyPermission(p1, p2, p3))
    return Failure<TicketDto>(403, "Forbidden");

Store in EF Core

Substratum handles JSON conversion automatically:

public sealed class Role : BaseEntity<Guid>
{
    public required string Name { get; set; }
    public required PermissionDefinition[] Permissions { get; set; }
    // ↑ stored as JSON: ["users.create","users.view"]
}

Load per user

Implement IPermissionHydrator:

public class PermissionHydrator : IPermissionHydrator
{
    public async Task<PermissionDefinition[]?> HydrateAsync(
        IServiceProvider sp, string userId, CancellationToken ct)
    {
        var db = sp.GetRequiredService<AppDbContext>();
        return await db.Users.AsNoTracking()
            .Where(u => u.Id == Guid.Parse(userId))
            .Select(u => u.Role.Permissions)
            .FirstOrDefaultAsync(ct);
    }
}

Loaded once per request, exposed via ICurrentUser.Permissions.


Events & Handlers

A built-in in-process event bus for domain events:

public sealed record TicketCreatedEvent(Guid TicketId, Guid UserId);

public sealed class TicketCreatedEventHandler : IEventHandler<TicketCreatedEvent>
{
    public Task HandleAsync(TicketCreatedEvent e, CancellationToken ct)
    {
        // side effects — send email, notify, log
        return Task.CompletedTask;
    }
}

Publish:

await _eventBus.PublishAsync(new TicketCreatedEvent(ticket.Id, user.Id), ct);

Handlers are discovered by the source generator and registered as scoped services.

Scaffold:

dotnet-sub new event --group Tickets --endpoint CreateTicket --name TicketCreated

Live Events (Real-time SSE)

Push server events to clients over Server-Sent Events — no SignalR, no WebSockets needed.

Enable

{
  "LiveEvents": {
    "Enabled": true,
    "Options": {
      "Path": "/v1/live-events",
      "ReconnectGracePeriodSeconds": 15,
      "KeepAliveIntervalSeconds": 30,
      "Provider": "Memory"
    }
  }
}

Use "Provider": "Redis" with a Redis.ConnectionString for multi-server clustering.

Auto-broadcast events

Mark any event with ILiveEvent — publishing it fans out to subscribers automatically:

public sealed record OrderCreatedEvent(Guid OrderId, string Customer) : ILiveEvent;

await _eventBus.PublishAsync(new OrderCreatedEvent(order.Id, order.Customer), ct);
// All subscribed SSE clients receive it

Manual push

Inject LiveEventDispatcher:

_live.Push("Notification", new { Message = "Hello" });
_live.PushToUser(userId, "Alert", new { Level = "warning" });
_live.Broadcast("SystemMessage", new { Text = "Maintenance in 5m" });

Client-side

const sse = new EventSource('/v1/live-events', { withCredentials: true });

sse.addEventListener('OrderCreatedEvent', e => {
  console.log('New order:', JSON.parse(e.data));
});

await fetch('/v1/live-events/subscribe', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ events: ['OrderCreatedEvent', 'ChatMessage'] }),
});

Reconnects within the grace period restore subscriptions automatically.

Online/offline tracking

Implement ILiveEventObserver:

public class PresenceObserver : ILiveEventObserver
{
    public async Task OnConnectedAsync(Guid userId, bool isFirstConnection, CancellationToken ct)
    {
        if (isFirstConnection) { /* mark user online */ }
    }

    public async Task OnDisconnectedAsync(Guid userId, bool isLastConnection, CancellationToken ct)
    {
        if (isLastConnection) { /* mark user offline, set last-seen */ }
    }
}

Authorization

Implement ILiveEventAuthorizer to gate who can subscribe to which events.


Background Jobs

Scaffold a job:

# Global scope (singleton job)
dotnet-sub new job --name SendDailyReport --scope Global --type Simple

# Feature-scoped with typed arguments
dotnet-sub new job --name ProcessOrder --scope Group --group Orders --type WithArgs

# Endpoint-scoped
dotnet-sub new job --name NotifyUser --scope Endpoint --group Users --endpoint CreateUser

Jobs integrate with your DI container and IDbContext.


Audit Logging

Implement IAuditableLog on your DbContext:

public class AppDbContext : DbContext, IAuditableLog
{
    public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
    // ...
}

Every SaveChangesAsync writes audit records in the same transaction: entity type, entity ID, action (Create/Update/Delete), timestamp, user ID, and property-level old/new values.

External audit store

For sending to ElasticSearch, a message queue, or a separate logging service, implement IAuditStore:

public class ExternalAuditStore : IAuditStore
{
    public Task StoreAsync(IReadOnlyList<AuditEntry> entries, CancellationToken ct)
    {
        // ship to Kafka, ELK, Datadog, ...
    }
}

File Storage

One interface, three providers. Configure once, use everywhere.

{
  "FileStorage": {
    "Enabled": true,
    "Options": {
      "Provider": "Local",
      "Container": "uploads",
      "MaxFileSizeBytes": 52428800,
      "AllowedExtensions": [".jpg", ".png", ".pdf", ".docx"]
    }
  }
}

Providers: Local, S3, AzureBlob (enable them under Aws.S3 / Azure.BlobStorage).

await storage.UploadAsync("documents/report.pdf", stream, "application/pdf", ct);
var data   = await storage.DownloadAsync("documents/report.pdf", ct);
bool there = await storage.ExistsAsync("documents/report.pdf", ct);
await storage.DeleteAsync("documents/report.pdf", ct);

// Target a specific provider/container explicitly
await storage.UploadAsync(StorageProvider.S3, "my-bucket", "docs/x.pdf", stream, ct: ct);

Image Processing

IImageService is always available — resize, WebP-convert, strip metadata, generate BlurHash:

using var r = await images.ProcessAsync(upload.OpenReadStream(), new ImageProcessingOptions
{
    MaxWidth = 800, MaxHeight = 600, Quality = 80
});
await storage.UploadAsync("images/photo.webp", r.Content, r.ContentType, ct);

// BlurHash placeholder for progressive loading
string hash = await images.BlurHashAsync(upload.OpenReadStream());
// "LEHV6nWB2yk8pyo0adR*.7kCMdnj"

Defaults: 512×512, quality 70, WebP, metadata stripped.


Localization

  1. Drop .resx files into Resources/:
Resources/SharedResource.en.resx
Resources/SharedResource.ar.resx
  1. Add a marker class:
public class SharedResource { }
  1. Configure the default:
{ "Localization": { "DefaultCulture": "en" } }
  1. Use it:
private readonly IStringLocalizer<SharedResource> _t;

return Success(_t["UserCreated"], data);

Supported cultures are detected automatically from your .resx filenames.


OpenAPI Docs

Docs are served at /scalar/v1 (Scalar UI) when enabled:

{
  "OpenApi": {
    "Enabled": true,
    "Options": {
      "Servers": [
        { "Url": "https://api.myapp.com", "Description": "Production" },
        { "Url": "https://localhost:5000", "Description": "Local" }
      ]
    }
  }
}

Per-endpoint summaries:

public partial class ListUsersSummary : EndpointSummary<ListUsersEndpoint>
{
    protected override void Configure()
    {
        Description = "Lists all users with pagination support.";
        Response<List<UserDto>>(200, "Users retrieved successfully");
        Response(404, "No users found");
    }
}

Split docs into groups (public / admin / internal)

public static partial class DocGroups : IDocGroupRegistry
{
    public static readonly DocGroupDefinition PublicApi = new(
        name: "Public API", url: "public", isDefault: true);

    public static readonly DocGroupDefinition AdminApi = new(
        name: "Admin API", url: "admin", permission: AppPermissions.Admin_Access);
}

Assign endpoints: route.DocGroup(DocGroups.AdminApi);

Groups with a permission are gated behind API-key or Basic-auth.


Firebase

Push notifications

{
  "Firebase": {
    "Messaging": {
      "Enabled": true,
      "Options": { "Credential": "BASE64_SERVICE_ACCOUNT_JSON" }
    }
  }
}

When enabled, the FirebaseMessaging client is available for injection.

App Check

{
  "Firebase": {
    "AppCheck": {
      "Enabled": true,
      "Options": {
        "ProjectId": "your-project",
        "ProjectNumber": "123456789"
      }
    }
  }
}
bool valid = await appCheck.VerifyAppCheckTokenAsync(token, ct);

Infrastructure Toggles

All configured via appsettings.json. Every feature is a clean Enabled flag + Options section.

CORS

{
  "Cors": {
    "AllowedOrigins": ["https://app.myapp.com"],
    "AllowedMethods": ["GET","POST","PUT","DELETE","PATCH"],
    "AllowedHeaders": ["Content-Type","Authorization"],
    "AllowCredentials": true,
    "MaxAgeSeconds": 600
  }
}

Rate Limiting

{
  "RateLimiting": {
    "Enabled": true,
    "Options": {
      "GlobalPolicy": "Default",
      "RejectionStatusCode": 429,
      "Policies": {
        "Default": { "Type": "FixedWindow", "PermitLimit": 100, "WindowSeconds": 60 },
        "Strict":  { "Type": "SlidingWindow", "PermitLimit": 10, "WindowSeconds": 60, "SegmentsPerWindow": 6 }
      }
    }
  }
}

Types: FixedWindow, SlidingWindow, TokenBucket, Concurrency. Per-endpoint: route.Options(o => o.RequireRateLimiting("Strict")); Custom partitioning: implement IRateLimitPartitioner.

Distributed Cache

{
  "DistributedCache": {
    "Enabled": true,
    "Options": {
      "Provider": "Redis",
      "Redis": { "ConnectionString": "localhost:6379", "InstanceName": "DC_" }
    }
  }
}

Providers: Memory, Redis. Registers IDistributedCache.

Health Checks

{ "HealthChecks": { "Enabled": true, "Options": { "Path": "/healthz" } } }

DbContext health is included automatically. Add more:

return await SubstratumApp.RunAsync(args, options =>
{
    options.HealthChecks.Options.HealthChecksBuilder = b =>
    {
        b.AddRedis("localhost:6379");
        b.AddUrlGroup(new Uri("https://api.upstream.com/health"), "upstream");
    };
});

Response Compression

{ "ResponseCompression": { "Enabled": true, "Options": { "EnableForHttps": true, "Providers": ["Brotli","Gzip"] } } }

Forwarded Headers (behind a proxy)

{ "ForwardedHeaders": { "Enabled": true, "Options": { "ForwardedHeaders": ["XForwardedFor","XForwardedProto"] } } }

Request Limits

{ "RequestLimits": { "MaxRequestBodySizeBytes": 52428800, "MaxMultipartBodyLengthBytes": 134217728 } }

Static Files

{ "StaticFiles": { "Enabled": true, "Options": { "RootPath": "wwwroot", "RequestPath": "" } } }

Error Handling

{ "ErrorHandling": { "IncludeExceptionDetails": false } }

Always set this to false in production.

Logging (Serilog)

Fully configured in appsettings.json. Built-in sensitive-data masking enricher:

{
  "Serilog": {
    "MinimumLevel": { "Default": "Information" },
    "Enrich": [
      "FromLogContext",
      {
        "Name": "WithSensitiveDataMasking",
        "Args": {
          "options": {
            "MaskValue": "*****",
            "MaskProperties": [{ "Name": "Password" }, { "Name": "SecretKey" }]
          }
        }
      }
    ],
    "WriteTo": [{ "Name": "Console" }]
  }
}

AWS Secrets Manager

Load secrets into IConfiguration at startup:

{
  "Aws": {
    "SecretsManager": {
      "Enabled": true,
      "Options": {
        "Region": "us-east-1",
        "SecretArns": ["arn:aws:secretsmanager:us-east-1:123:secret:my-secret"]
      }
    }
  }
}

Cloud Storage

{
  "Aws":   { "S3":           { "Enabled": true, "Options": { "Region": "us-east-1", "AccessKey": "...", "SecretKey": "..." } } },
  "Azure": { "BlobStorage":  { "Enabled": true, "Options": { "ConnectionString": "..." } } }
}

When enabled, IAmazonS3 and BlobServiceClient are directly injectable — use them or go through the unified IFileStorage.


CLI Tool: dotnet-sub

dotnet tool install -g Substratum.Tools

Scaffolding

Command What it creates
dotnet-sub new webapp MyApp Complete project (Program.cs, config, DbContext, permissions, sample endpoint)
dotnet-sub new endpoint --group Users --name CreateUser --method Post --route /users --permission Users_Create Endpoint + request + response + validator + summary
dotnet-sub new endpoint --group Users --name ListUsers --method Get --route /users --response-type PaginatedResult Paginated endpoint
dotnet-sub new entity --name Product Entity + EF configuration
dotnet-sub new event --group Tickets --endpoint CreateTicket --name TicketCreated Event type + handler
dotnet-sub new job --name SendEmailJob --scope Global --type Simple Background job

Endpoint options

Flag Values
--method Get, Post, Put, Delete, Patch
--endpoint-type Standard, Void, Stream
--response-type SingleResult, PaginatedResult
--use-result-wrapper Yes, No
--permission A permission code (e.g., Users_Create)

Job options

Flag Values
--scope Global, Group, Endpoint
--type Simple, WithArgs

Database

dotnet-sub migrations add InitialCreate
dotnet-sub migrations add AddProducts --context AnalyticsDbContext

dotnet-sub database update
dotnet-sub database update --context AnalyticsDbContext

dotnet-sub database sql                            # output migrations.sql
dotnet-sub database sql -o deploy.sql
dotnet-sub database sql --from V1 --to V2

Code migration (upgrading Substratum)

dotnet-sub migrate                  # rewrite v1.x code to current API
dotnet-sub migrate --dry-run        # preview changes
dotnet-sub migrate --path ./src     # target a specific directory

Handles base class renames, Configure() signature changes, method-prefixing, and more.


MCP Server (for AI Assistants)

Substratum.Mcp is a Model Context Protocol server that lets AI assistants like Claude, Cursor, and Windsurf understand, scaffold, and modify your Substratum project with expert-level fluency.

Install

dotnet tool install -g Substratum.Mcp

Register with Claude Code / Desktop

{
  "mcpServers": {
    "substratum": {
      "command": "dotnet-sub-mcp"
    }
  }
}

No flags, no config files. When your MCP client exposes a workspace root, the server picks it up automatically — most tool calls no longer need a projectPath argument.

What you can ask your AI assistant to do

  • "Scaffold a Products feature with CRUD endpoints" — the AI uses the new_feature prompt which orchestrates entity → migration → endpoints → validators → build
  • "Add a permission for exporting invoices and gate the endpoint"scaffold kind=permission plus endpoint update
  • "Analyze my project and tell me what's missing" — Roslyn scan flags missing FKs, indexes, naming violations
  • "Upgrade this project to the latest Substratum"upgrade_project prompt: analyse → dry-run → confirm → apply → build
  • "Design a backend for a multi-tenant SaaS"design_for_domain prompt produces a validated JSON design

Surface

The server exposes 13 tools, 6 resources, and 5 prompts. Every tool is annotated (ReadOnly, Destructive, Idempotent, OpenWorld) so clients can render safe permission UIs.

Tools
Tool Annotations What it does
scaffold Destructive Unified scaffolder. kind: project, entity, endpoint, event, job, service, doc_group, permission
analyze_project ReadOnly, Idempotent Roslyn scan — returns entities, endpoints, permissions, DbContexts, events, jobs, enabled features
design_validate ReadOnly, Idempotent Structural validation of a backend design; optional useSampling=true adds an LLM qualitative review
generate_code Destructive Endpoint handler logic / validators / seeders. intent: logic, validation, seeder
read_config ReadOnly, Idempotent Reads appsettings.json, optionally scoped to a section
update_config Destructive, Idempotent Deep-merges a config object into a feature section
db Destructive EF operations. action: migrate_add, update, sql. update asks for user confirmation via elicitation.
upgrade Destructive Cross-version migration. action: analyze, endpoints, generics, renames. Dry-run by default; apply mode asks for confirmation.
build Idempotent dotnet build with parsed diagnostics and streaming progress
fs_list ReadOnly, Idempotent List project files filtered by extension
fs_read ReadOnly, Idempotent Read a file (optional line range)
fs_write Destructive, Idempotent Write to a file (creates parent dirs)
run_command Destructive Allow-listed dotnet CLI commands (build, test, restore, publish, ef, tool, add, …)

Every structured tool declares an outputSchema so clients can parse responses without guesswork.

Resources

Static content — load once, reference as often as the AI needs without burning tool round-trips:

URI What it is
substratum://conventions Full coding conventions (25+ categories)
substratum://schema/appsettings JSON Schema for appsettings.json
substratum://skills Catalog of best-practice skill packs
substratum://skills/{name} One skill pack. Names: database_design, entity_design, endpoint_design, endpoint_logic, validation, ef_core, linq, security
substratum://guides Catalog of setup guides
substratum://guides/{name} One setup guide. Names: overview, user-and-roles, permission-hydrator, session-validator, api-key-auth, basic-auth, database-seeder

Resource templates use AllowedValues completions — your AI client can tab-complete valid skill/guide names.

Prompts

Orchestrated workflows — each expands into a detailed, ordered plan the AI executes end-to-end:

Prompt Does
new_feature End-to-end: design → validate → scaffold entity → migration → endpoints → logic → validators → build
full_crud Generates all 5 CRUD endpoints for an existing entity
upgrade_project Safe upgrade: analyse → dry-run → confirm → apply → build
review_design Reviews a proposed design against conventions + skills, with LLM qualitative feedback
design_for_domain Produces a validated backend design from a natural-language domain description
MCP capabilities used

The server takes full advantage of modern MCP features:

  • Tool annotations — clients render correct permission UIs (read-only tools don't prompt; destructive tools do)
  • Structured outputs — every tool returns typed content + declared outputSchema
  • Progress notifications — long-running ops (build, upgrade, db, scaffold) stream progress
  • Cancellation tokens — every async tool cancels cleanly when the client aborts
  • RootsprojectPath falls back to the client's workspace root, so tool calls are shorter
  • Samplingdesign_validate can ask the LLM for qualitative design feedback
  • Elicitation — destructive ops (db action=update, upgrade dryRun=false) ask the user to confirm via the client's UI
  • Completions — resource template parameters (skill/guide names) offer tab-complete

Reference

Interfaces you implement

Interface When Purpose
IBasicAuthValidator When Basic Auth enabled Validate username/password
IApiKeyValidator When API Keys enabled Validate API keys
IRefreshTokenStore When using JWT refresh tokens Persist & rotate refresh tokens
ISessionValidator Optional Runs on every authenticated request
IPermissionHydrator Optional Load user's permissions into claims
IAppResolver Optional Validate multi-app IDs
IDbContextInitializer<T> Optional Seed data at startup
IAuditStore Optional Ship audit entries externally
ILiveEventObserver Optional React to user connect/disconnect
ILiveEventAuthorizer Optional Gate Live Events subscriptions
IRateLimitPartitioner Optional Custom rate-limit partition keys
IEventHandler<T> Per event Handle domain events

Services you inject

Service Available Purpose
ICurrentUser Always User ID, app ID, permissions
IPasswordHasher Always Hash/verify passwords
ITotpProvider Always TOTP 2FA
IImageService Always Resize / WebP / BlurHash
EventBus Always Publish domain events
IJwtBearer When JWT enabled Create/refresh JWTs
ICookieAuth When Cookie auth enabled Sign in/out
IFileStorage When FileStorage enabled Upload/download/delete
LiveEventDispatcher When Live Events enabled Push SSE messages
IDistributedCache When DistributedCache enabled Redis/memory caching
IFirebaseAppCheck When AppCheck enabled Verify App Check tokens
IAmazonS3 When Aws.S3 enabled Raw S3 client
BlobServiceClient When Azure.BlobStorage enabled Raw Azure Blob client

Base classes

Class Use for
Endpoint<TReq, TRes> Standard JSON endpoint
Endpoint<TReq> Void endpoint
StreamEndpoint<TReq> / StreamEndpoint<TReq, TRes> Server-Sent Events
BaseEntity<T> Domain entity (soft-delete + timestamps)
Validator<T> FluentValidation validator
EndpointSummary<TEndpoint> OpenAPI metadata

Data types

Type Use for
Result<T> Uniform success/error envelope
PaginatedResult<T> Paginated list response
Unit Empty response body
PermissionDefinition Typed permission with group metadata
DocGroupDefinition OpenAPI doc group

Full appsettings.json Reference

Every option, every default:

{
  "ServerEnvironment": "Production",
  "Serilog": {
    "Using": ["Serilog.Sinks.Console", "Serilog.Enrichers.Sensitive"],
    "MinimumLevel": {
      "Default": "Information",
      "Override": {
        "Microsoft": "Warning",
        "Microsoft.Hosting.Lifetime": "Information",
        "System": "Warning",
        "Microsoft.EntityFrameworkCore": "Warning"
      }
    },
    "Enrich": [
      "FromLogContext",
      "WithMachineName",
      "WithThreadId",
      {
        "Name": "WithSensitiveDataMasking",
        "Args": {
          "options": {
            "MaskValue": "*****",
            "MaskProperties": [
              { "Name": "Password" },
              { "Name": "HashPassword" }
            ]
          }
        }
      }
    ],
    "Properties": { "Application": "MyApp" },
    "WriteTo": [{ "Name": "Console" }]
  },
  "Cors": {
    "AllowedOrigins": ["https://localhost:3000"],
    "AllowedMethods": ["GET", "POST", "PUT", "DELETE", "PATCH"],
    "AllowedHeaders": ["Content-Type", "Authorization", "X-APP-ID", "X-API-KEY"],
    "AllowCredentials": true,
    "MaxAgeSeconds": 600
  },
  "Authentication": {
    "JwtBearer": {
      "Enabled": true,
      "Options": {
        "SecretKey": "YOUR_SECRET_KEY_MUST_BE_AT_LEAST_32_CHARACTERS_LONG",
        "Issuer": "http://localhost:5000",
        "Audience": "MyApp",
        "Expiration": "1.00:00:00",
        "RefreshExpiration": "7.00:00:00",
        "ClockSkew": "00:02:00",
        "RequireHttpsMetadata": true
      }
    },
    "Cookie": {
      "Enabled": true,
      "Options": {
        "Scheme": "Cookies",
        "CookieName": ".Substratum.Auth",
        "Expiration": "365.00:00:00",
        "SlidingExpiration": true,
        "Secure": true,
        "HttpOnly": true,
        "SameSite": "Lax",
        "AppIdHeaderName": "X-APP-ID"
      }
    },
    "BasicAuthentication": {
      "Enabled": false,
      "Options": { "Realm": "MyApp" }
    },
    "ApiKeyAuthentication": {
      "Enabled": false,
      "Options": { "Realm": "MyApp", "KeyName": "X-API-KEY" }
    }
  },
  "EntityFramework": {
    "Default": {
      "Provider": "Npgsql",
      "ConnectionString": "Host=localhost;Database=mydb;Username=postgres;Password=password",
      "CommandTimeoutSeconds": 30,
      "EnableSeeding": true,
      "Logging": {
        "EnableDetailedErrors": true,
        "EnableSensitiveDataLogging": true
      },
      "RetryPolicy": {
        "Enabled": true,
        "Options": { "MaxRetryCount": 3, "MaxRetryDelaySeconds": 5 }
      },
      "SecondLevelCache": {
        "Enabled": true,
        "Options": { "KeyPrefix": "EF_", "Provider": "Memory" }
      }
    }
  },
  "ErrorHandling": { "IncludeExceptionDetails": false },
  "Localization": { "DefaultCulture": "en" },
  "OpenApi": {
    "Enabled": true,
    "Options": {
      "Servers": [{ "Url": "https://localhost:5000", "Description": "Local Development" }]
    }
  },
  "StaticFiles": {
    "Enabled": false,
    "Options": { "RootPath": "wwwroot", "RequestPath": "" }
  },
  "HealthChecks": {
    "Enabled": true,
    "Options": { "Path": "/healthz" }
  },
  "Aws": {
    "S3": {
      "Enabled": false,
      "Options": {
        "Endpoint": null,
        "Region": "us-east-1",
        "ForcePathStyle": false,
        "AccessKey": "YOUR_ACCESS_KEY",
        "SecretKey": "YOUR_SECRET_KEY"
      }
    },
    "SecretsManager": {
      "Enabled": false,
      "Options": {
        "Region": "us-east-1",
        "SecretArns": ["YOUR_SECRET_ARN"]
      }
    }
  },
  "Azure": {
    "BlobStorage": {
      "Enabled": false,
      "Options": { "ConnectionString": "YOUR_CONNECTION_STRING" }
    }
  },
  "Firebase": {
    "Messaging": {
      "Enabled": false,
      "Options": { "Credential": "BASE_64_ENCODED_SERVICE_ACCOUNT_JSON" }
    },
    "AppCheck": {
      "Enabled": false,
      "Options": {
        "ProjectId": "YOUR_PROJECT_ID",
        "ProjectNumber": "YOUR_PROJECT_NUMBER",
        "EnableEmulator": false,
        "EmulatorTestToken": "TEST_TOKEN"
      }
    }
  },
  "DistributedCache": {
    "Enabled": true,
    "Options": {
      "Provider": "Redis",
      "Redis": { "ConnectionString": "localhost:6379", "InstanceName": "DC_" }
    }
  },
  "ResponseCompression": {
    "Enabled": true,
    "Options": {
      "EnableForHttps": true,
      "Providers": ["Brotli", "Gzip"],
      "MimeTypes": ["text/plain", "application/json", "text/html"]
    }
  },
  "ForwardedHeaders": {
    "Enabled": false,
    "Options": {
      "ForwardedHeaders": ["XForwardedFor", "XForwardedProto"],
      "KnownProxies": [],
      "KnownNetworks": []
    }
  },
  "RequestLimits": {
    "MaxRequestBodySizeBytes": 52428800,
    "MaxMultipartBodyLengthBytes": 134217728
  },
  "FileStorage": {
    "Enabled": true,
    "Options": {
      "Provider": "Local",
      "Container": "uploads",
      "MaxFileSizeBytes": 52428800,
      "AllowedExtensions": [".jpg", ".png", ".pdf", ".docx"]
    }
  },
  "RateLimiting": {
    "Enabled": false,
    "Options": {
      "GlobalPolicy": "Default",
      "RejectionStatusCode": 429,
      "Policies": {
        "Default": {
          "Type": "FixedWindow",
          "PermitLimit": 100,
          "WindowSeconds": 60,
          "QueueLimit": 0
        }
      }
    }
  },
  "LiveEvents": {
    "Enabled": false,
    "Options": {
      "Path": "/v1/live-events",
      "ReconnectGracePeriodSeconds": 15,
      "KeepAliveIntervalSeconds": 30,
      "CleanupIntervalSeconds": 10,
      "Provider": "Memory",
      "Redis": { "ConnectionString": "", "ChannelPrefix": "live-events" }
    }
  }
}

License

MIT — do anything you want with it.


Built for .NET developers who'd rather ship features than wire up middleware.

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
2.24.0 48 4/17/2026
2.23.0 56 4/17/2026
2.22.0 50 4/17/2026
2.21.0 56 4/17/2026
2.20.0 45 4/17/2026
2.19.0 64 4/17/2026
2.18.0 61 4/17/2026
2.15.0 91 4/11/2026
2.14.0 81 4/11/2026
2.12.0 91 4/11/2026
2.10.0 88 4/10/2026
2.9.0 89 4/7/2026
2.8.0 92 4/3/2026
2.6.0 117 3/28/2026
2.5.1 92 3/28/2026
2.5.0 93 3/27/2026
2.4.0 101 3/27/2026
2.3.3 98 3/27/2026
2.3.1 86 3/27/2026
2.1.1 94 3/26/2026
Loading failed