Fly.Sdk.Core
1.25.1
See the version list below for details.
dotnet add package Fly.Sdk.Core --version 1.25.1
NuGet\Install-Package Fly.Sdk.Core -Version 1.25.1
<PackageReference Include="Fly.Sdk.Core" Version="1.25.1" />
<PackageVersion Include="Fly.Sdk.Core" Version="1.25.1" />
<PackageReference Include="Fly.Sdk.Core" />
paket add Fly.Sdk.Core --version 1.25.1
#r "nuget: Fly.Sdk.Core, 1.25.1"
#:package Fly.Sdk.Core@1.25.1
#addin nuget:?package=Fly.Sdk.Core&version=1.25.1
#tool nuget:?package=Fly.Sdk.Core&version=1.25.1
Fly.Sdk.Core
Core shared library for building FlyOS-compatible business app microservices on .NET 9.
Installation
<PackageReference Include="Fly.Sdk.Core" Version="1.*" />
What's Included
| Module | Namespace | What It Provides |
|---|---|---|
| Multi-tenancy | Fly.Sdk.Core.MultiTenancy |
ITenantContext, TenantContext, TenantEntity, AuditableEntity, TenantMiddleware, MultiTenantDbContext with automatic global query filters |
| Authentication | Fly.Sdk.Core.Auth |
ClaimsExtensions for extracting tenant/user from JWT, FlyAuthorizeAttribute for Cerbos-based authorization |
| Caching | Fly.Sdk.Core.Caching |
IFlyCache / RedisFlyCache — Redis-backed cache with consistent key conventions |
| i18n | Fly.Sdk.Core.I18n |
TranslatableEntity<T> base class for multilingual entities |
| Middleware | Fly.Sdk.Core.Middleware |
CorrelationIdMiddleware — injects/propagates X-Correlation-ID on every request |
| API Models | Fly.Sdk.Core.Models |
ApiResponse<T>, ApiResponse, ApiError, PagedRequest — standard envelope types |
| Swagger / OpenAPI | Fly.Sdk.Core.Swagger |
IncludeFlyOpenApiXmlComments() — registers entry-assembly + every Fly.Sdk.*.xml doc-comments file with Swashbuckle. ApiDocsManifest record — declares OpenAPI document location for the gateway aggregator (sent on POST /api/apps/{appId}/onboard). ApiDocsPathValidator.IsValid(path, out reason) — shared SSRF-safe path validator with fixed-point decode loop; used by Controller onboarding (write-time) and Gateway upstream proxy (fetch-time defence-in-depth). |
Quick Start
Service Registration
var builder = WebApplication.CreateBuilder(args);
// Registers TenantContext, IFlyCache (Redis), IHttpContextAccessor, MemoryCache
builder.Services.AddFlyCore();
// Add your DbContext using the shared multi-tenant base
builder.Services.AddDbContext<MyAppDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("Default")));
var app = builder.Build();
// Resolves tenant from JWT sub-claim and populates ITenantContext
app.UseMiddleware<TenantMiddleware>();
app.UseMiddleware<CorrelationIdMiddleware>();
app.MapControllers();
app.Run();
Multi-Tenant Entity
using Fly.Sdk.Core.MultiTenancy;
public class Project : TenantEntity
{
public string Name { get; set; } = string.Empty;
public bool IsActive { get; set; } = true;
}
Multi-Tenant DbContext
using Fly.Sdk.Core.MultiTenancy;
public class MyAppDbContext : MultiTenantDbContext
{
public MyAppDbContext(DbContextOptions<MyAppDbContext> options, ITenantContext tenant)
: base(options, tenant) { }
public DbSet<Project> Projects => Set<Project>();
}
MultiTenantDbContext automatically applies a global query filter e.TenantId == currentTenantId on all TenantEntity sets, and stamps TenantId on SaveChanges.
API Response Envelope
[ApiController]
[Route("api/projects")]
public class ProjectsController : ControllerBase
{
[HttpGet]
public IActionResult GetAll()
{
var projects = _service.GetAll();
return Ok(ApiResponse<List<ProjectDto>>.Ok(projects));
}
[HttpPost]
public IActionResult Create(CreateProjectRequest req)
{
if (!ModelState.IsValid)
return BadRequest(ApiResponse.Fail("VALIDATION_ERROR", "Invalid input"));
var project = _service.Create(req);
return Created($"api/projects/{project.Id}", ApiResponse<ProjectDto>.Ok(project));
}
}
Caching
public class ProjectService
{
private readonly IFlyCache _cache;
public async Task<Project?> GetByIdAsync(Guid id)
{
return await _cache.GetOrSetAsync(
$"project:{id}",
() => _db.Projects.FindAsync(id).AsTask(),
TimeSpan.FromMinutes(15));
}
}
Swagger / OpenAPI
AddFlyApiDocs is the bundled "good defaults" registration — single call, replaces the SwaggerDoc / XML / Bearer-security boilerplate every backend would otherwise repeat:
using Fly.Sdk.Core.Swagger;
builder.Services.AddFlyApiDocs(opts =>
{
opts.Title = "My API"; // optional; defaults to entry-assembly name
opts.Description = "Optional summary shown in Swagger UI";
});
What that one call does:
- registers
SwaggerDoc(opts.Version, ...)withOpenApiInfopopulated fromTitle/Version/Description; - calls
IncludeFlyOpenApiXmlComments()so the entry-assembly XML and everyFly.Sdk.*.xmlnext to the binary are picked up; - when
AddSecurityScheme = true(default) registers a Bearer JWT security definition + global requirement so the Swagger UI Authorize dialog accepts a token; - finally invokes
opts.PostConfigure?.Invoke(swaggerGenOptions)so callers can layer on tags, schema filters, OAuth2 flows, or anything else without giving up the bundled defaults.
For an explicitly anonymous service (e.g. a public landing doc) opt out of the Bearer scheme:
builder.Services.AddFlyApiDocs(opts => opts.AddSecurityScheme = false);
Lower-level access remains available when something more bespoke is needed:
builder.Services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new() { Title = "My API", Version = "v1" });
c.IncludeFlyOpenApiXmlComments();
});
The consuming project must enable <GenerateDocumentationFile>true</GenerateDocumentationFile> and <CopyDocumentationFileToOutputDirectory>true</CopyDocumentationFileToOutputDirectory> in its csproj for the entry-assembly XML to exist at runtime.
Declaring your OpenAPI document for the gateway aggregator
Build an ApiDocsManifest and pass it on the trailing ApiDocs parameter of AppOnboardingRequest when your service onboards. The Controller persists it next to your gateway route hints; the gateway-side aggregator reads it on its next poll cycle to populate the unified Swagger UI dropdown.
using Fly.Sdk.Core.Swagger;
// AppOnboardingRequest has many positional parameters; use named args so this
// snippet compiles as written. Pass null for any sections you don't reconcile
// in this onboarding call.
var manifest = new AppOnboardingRequest(
Permissions: null,
Roles: null,
NotificationTemplates: null,
ServiceAccess: null,
GatewayRoutes: null,
HelpArticles: null,
ApiDocs: new ApiDocsManifest(
Path: "/swagger/v1/swagger.json",
Title: "Circles API",
Version: "1.0.0",
RequiredScopes: new() { "fly-api" }));
To remove a previously published descriptor, send new ApiDocsManifest("") — the empty-path tombstone sentinel.
The Swagger/ namespace will grow as the gateway aggregator and richer Swashbuckle wiring land in upcoming releases.
Scoped inter-service calls
InterServiceTokenHandler (registered automatically by AddFlyServiceClient) attaches a cached client_credentials Bearer token to every outbound request. By default the token is acquired with the catch-all scope inter-service and cached per client_id. Callers that need a different scope set on a single call attach a hint to the HttpRequestMessage:
using Fly.Sdk.Core.Http;
var req = new HttpRequestMessage(HttpMethod.Get, "https://controller/api/something");
req.WithInterServiceScopes("inter-service", "circles");
using var resp = await flyClient.HttpClient.SendAsync(req, ct);
The handler:
- normalises the supplied set (deduplicated, case-insensitive, sorted) so
["A", "b"]and["b", "A"]reach the same cache slot; - keys the token cache as
fly:inter-service-token:{clientId}:{sortedScopes}so a default-scope token and a custom-scope token never overwrite each other; - requests the joined scope string from OpenIddict on cache miss, falling back to
"inter-service"when no hint is set.
The stored OpenIddict client must already have been granted every requested scope — otherwise the token endpoint returns OpenIddict ID2051 and the call fails.
Configuration
Add to appsettings.json:
{
"Redis": {
"ConnectionString": "localhost:6379"
}
}
Dependencies
Microsoft.AspNetCore.App(framework reference)Microsoft.EntityFrameworkCore9.xMicrosoft.EntityFrameworkCore.Relational9.xMicrosoft.Extensions.Caching.StackExchangeRedis9.xStackExchange.Redis2.xFluentValidation.AspNetCore11.xOpenTelemetry.Extensions.Hosting1.xOpenTelemetry.Instrumentation.AspNetCore1.x
Related Packages
- Fly.Sdk.Messaging — MassTransit/RabbitMQ wiring and platform event contracts. Depends on this package.
| Product | Versions 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. |
-
net10.0
- AspNetCore.HealthChecks.NpgSql (>= 9.0.0)
- AspNetCore.HealthChecks.Rabbitmq (>= 9.0.0)
- AspNetCore.HealthChecks.Redis (>= 9.0.0)
- Azure.Identity (>= 1.17.2)
- FluentValidation.AspNetCore (>= 11.3.1)
- Fly.Sdk.Telemetry (>= 1.3.4)
- Microsoft.AspNetCore.DataProtection.StackExchangeRedis (>= 10.0.9)
- Microsoft.EntityFrameworkCore (>= 10.0.9)
- Microsoft.EntityFrameworkCore.Relational (>= 10.0.9)
- Microsoft.Extensions.Caching.StackExchangeRedis (>= 10.0.9)
- Microsoft.Extensions.Http.Resilience (>= 10.7.0)
- Microsoft.OpenApi (>= 2.7.5)
- Npgsql (>= 10.0.3)
- OpenTelemetry (>= 1.15.3)
- OpenTelemetry.Exporter.OpenTelemetryProtocol (>= 1.15.3)
- OpenTelemetry.Extensions.Hosting (>= 1.15.3)
- OpenTelemetry.Instrumentation.AspNetCore (>= 1.15.2)
- OpenTelemetry.Instrumentation.EntityFrameworkCore (>= 1.15.1-beta.1)
- OpenTelemetry.Instrumentation.Http (>= 1.15.1)
- OpenTelemetry.Instrumentation.Runtime (>= 1.15.1)
- OpenTelemetry.Instrumentation.StackExchangeRedis (>= 1.15.1-beta.1)
- StackExchange.Redis (>= 2.12.8)
- Swashbuckle.AspNetCore (>= 10.1.7)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
1.24.0: a missing Frontend:RemoteEntry is no longer silent. When an app declares an ExposedModule but does not configure Frontend:RemoteEntry, FlyAppRegistrationPayloads falls back to the SAMPLE TEMPLATE's federation port — so the app registers a catalog row that reads as healthy and fails only when the shell tries to open it. FlyAppRegistrationWorker now logs a Warning (EventId 5410, PlaceholderRemoteEntry) naming the app, the exposed module and the placeholder; a genuinely headless External App declares no ExposedModule and stays silent. Also 1.24.0: inter-service app surfaces finally work for External Apps. OwningAppGuard.CheckOwningApp compared the token's client_id to the route appId exactly, but only a Core App's confidential client carries the bare app id — an External App's is minted as `{appId}-service` by AppBootstrapController (its bare id is the public PKCE client). Every External App was therefore 403 APP_MISMATCH on every `.../app/{appId}` surface (Tasks, Chats, Calendar meetings, Projects); it stayed hidden because the pilot External App published to Tasks over the message bus instead of calling the HTTP surface. Ownership is now the shared predicate Fly.Sdk.Core.Auth.AppClientIds.Owns — exactly two accepted forms, bare and `-service`, ordinal — and AppClientIds (ServiceClientSuffix, ForService, HasServiceClientSuffix, Owns) is the single home for the convention that the Controller's bootstrap/onboarding/role-binding gates and the Comments brokered-write gate now all consume, so a rename cannot re-open the gap on one side. Tightening shipped with it: an app id ending in `-service` is refused at bootstrap and denied at the gate (new OwningAppGuard.ReservedAppIdCode = APP_ID_RESERVED), because app `x`'s inter-service client id is indistinguishable from an app named `x-service` — an escalation path a bare exact-match comparison already had. No platform app id ends in the suffix. Deny messages now name both ids AND the two accepted forms.
1.23.0: IControllerClient gains ResolveUsersAsync(tenantId, userIds) — bulk-resolve user ids to display identity + email over Controller's existing POST /api/internal/users/resolve (max 200/call), with the same graceful-empty-on-error contract as the other client methods. Enables inter-service callers (e.g. the Calendar meeting surface) to resolve attendee emails. Additive; NoOpControllerClient returns empty in standalone mode.
1.22.0: forwarded-headers trust is now enforceable. AddFlyServiceDefaults routes ForwardedHeadersOptions through the new public ForwardedHeadersConfigurator — set ForwardedHeaders:TrustedProxyNetworks (CIDRs) / :TrustedProxies (IPs) and it pins KnownIPNetworks/KnownProxies to the real Front-Door + ingress egress ranges AND pins ForwardLimit (default 2) so RemoteIpAddress is the real client and X-Forwarded-For is trusted ONLY from those proxies up to the pinned hop count. This closes the P0.5 gateway finding where clearing the known-lists silently trusted one XFF hop from ANY peer (shared-egress rate-limit bucket + client-spoofable). Unconfigured (dev/local) preserves the historical clear-both behavior byte-for-byte; no consumer change unless you opt in via the new keys. 1.21.0: tenant-wide file read is finally reversible. IFilesClient gains RevokeTenantReadAsync(fileId) and IFileReferences gains the bulk RevokeTenantReadAsync(ids), both idempotent, backed by the new files-manager route DELETE /api/file-permissions/tenant-read/{fileId}. Until now an app that opened tenant-wide read on publish had no way to close it again when the entity was hidden, so anyone who captured the file id kept downloading it; consumers were carrying TODO markers instead of a fix. Revoke authorization deliberately mirrors the grant (owner-or-admin plus a Cerbos share:revoke gate) — only the wildcard ALLOW rows on the file are removed, so wildcard deny rows, user/OU grants and folder-inherited grants are untouched. 1.20.0: a best-effort bootstrap (BootstrapRequired=false) now RETRIES in the background instead of getting a single shot. Previously a rung-0 failure caused by ordering — the Controller's Apps:RegistrationToken landing after the app pod booted — was permanent and silent: register/onboard/heartbeat still succeeded so /api/services stayed Healthy, while the app's SPA had no working login (ID2052 at /connect/authorize) until someone manually restarted the pod. The retry loop runs CONCURRENTLY with the rest of the ladder (it never gates it) and self-heals the moment the Controller-side cause is fixed; recovery emits the new BootstrapRecovered (5403) EventId, so alerting on "5401 with no following 5403" catches a bootstrap that never came back. 1.19.0: the app-registration ladder is now LOUD — per-rung FlyAppRegistrationState with fly.app.registration.* gauges, an app-registration health check (opt-in readiness gating via FailReadinessOnRegistrationFailure), and structured-EventId Error logging on bootstrap 4xx/5xx + worker fatal stop. 1.18.0: canonical Fly.Sdk.Core.Models.PagedResult<T> record (wire field 'total') is now the one shared paged envelope; the legacy PagedResponse<T> (wire field 'totalCount') is [Obsolete] but retained as a compile shim.