Notable changes to the kit, newest first.
10.0.0 — 2026-05-28
The first stable 10.0.0 release. fullstackhero is now a complete .NET 10 modular monolith plus two React 19 apps — and you get the full source, no black-box runtime packages. Available today via git clone or the GitHub template; the fsh CLI and the dotnet new fsh template publish to NuGet shortly.
- Backend — .NET 10 / EF Core 10 modular monolith (Vertical Slice + source-generated Mediator CQRS) across 10 modules: Identity, Multitenancy, Billing, Catalog, Tickets, Chat, Files, Webhooks, Auditing, and Notifications. Multitenant by default (Finbuckle), JWT + ASP.NET Identity, HybridCache on Valkey, Hangfire jobs, presigned S3/MinIO storage, OpenAPI + Scalar, and Serilog + OpenTelemetry.
- Front-ends — two React 19 + Vite 7 + TypeScript apps: an operator console (
admin) and a tenant app (dashboard), with TanStack Query v5, Tailwind v4, and SignalR/SSE real-time. - One-command local dev — .NET Aspire brings up Postgres + pgAdmin, Valkey + RedisInsight, MinIO, the migrator, demo data, the API, and both front-ends. Docker Compose and AWS/Terraform cover deployment.
- Tested & enforced — 1,600+ backend tests (xUnit, Testcontainers, NetArchTest boundaries) and 200+ Playwright E2E tests, with path-scoped backend/frontend CI and warnings-as-errors.
- Polish in this release — the
fshCLI gained a--versionflag and a corrected (semver-aware) update check; the unimplemented--db sqlserverscaffold option was removed (PostgreSQL is the supported provider); AppHost resource names are namespaced per app; and a batch of scaffold/DX fixes landed (see the dated entries below).
See the dated entries below for the complete list of changes that shipped into 10.0.0.
2026-05-30
- Cross-tenant hardening across billing, subscriptions, and tenant management (security fixes). A deep audit found several handlers that read or mutated data scoped only by a caller-supplied id rather than the caller’s tenant. Because
BillingDbContextis intentionally non-tenant-filtered (so the root operator can see across tenants), each handler must scope explicitly — and several didn’t. A tenant admin (who holds the basicBilling.View/Billing.Managepermissions) could read, issue, pay, or void another tenant’s invoices by id, reassign or cancel another tenant’s subscription via a bodytenantId, read or fabricate another tenant’s usage, or trigger platform-wide invoice generation. The by-id read/PDF paths and every mutation path now gate on the root operator — the operator acts cross-tenant, every other tenant is pinned to its own — andPOST /api/v1/billing/invoices/generateis now operator-only. Separately, a role-permission filter only stripped aPermissions.Root.name prefix that matches no real operator permission, so a non-root tenant admin withRoles.Updatecould grant their own role the operator-onlyTenants.*/Platform.*permissions and escalate to managing every tenant; the filter now keys off the registeredIsRootflag. Existing isolation tests missed all of this because they always authenticate with a matching tenant header — they never exercised one tenant’s token acting on another’s data. New integration tests cover each scenario. (Thetenant-header-vs-JWT-claim path was investigated and is not affected — Finbuckle’s claim strategy binds the resolved tenant to the JWT claim for non-root callers.) - The API now serializes enums as their string names (contract change). Every enum in an API response is emitted as its name (
"Active","Paid","Security") instead of a numeric value, via a globalJsonStringEnumConverter; reading still accepts either form, so request bodies are unaffected.[Flags]enums (AuditTag,BodyCapture) stay numeric. Both bundled React apps already mirror this as string-union types — but if you consume the API from your own client, update any code that switched on numeric enum values. Previously only a couple of modules opted in per-type, so values like a subscription’sstatusserialized as0and surfaced as a stray “0” in the dashboard. - Billing correctness. The monthly usage/overage invoice was silently skipped for any month that already had a subscription invoice (the idempotency check ignored the invoice purpose), so overage went unbilled — it’s now scoped to the usage invoice. A same-plan renewal advanced the tenant’s validity but left the subscription’s end date unchanged, so the dashboard’s subscription term drifted behind the enforced validity; a renewal now extends the subscription term too. Tenant provisioning now checks the admin-user creation result instead of ignoring it (a silent failure previously marked a tenant “provisioned” with no usable admin login). Voiding an invoice is idempotent, invoice-list page size is capped at 100, and the root operator tenant’s validity can no longer be adjusted.
- Front-end polish. The admin console hides plan/invoice/tenant action buttons from operators who lack the matching permission (they previously appeared and failed with
403on submit) and shows a real error state on the invoice page instead of a stuck “Loading…”. The dashboard landing page’s validity now reflects an in-grace or expired tenant (with a persistent expired banner) instead of a healthy day count, surfaces subscription/invoice load errors instead of masking them as an empty state, and paginates the invoice list.
2026-05-28
-
Tenant billing is now complete end-to-end — expiry/renewal emails, PDF invoices, and a tenant-facing billing view. Building on the plan-driven subscription/invoice lifecycle, this round finishes the SaaS billing story. A daily Hangfire scan (
tenant-expiry-scan, 02:00 UTC) classifies every active tenant as nearing expiry, in grace, or expired and emails the tenant admin — deduped so each state notifies once per validity window (and re-arms automatically on renewal). Issuing an invoice now also emails the tenant. Invoices are downloadable as PDF (GET /api/v1/billing/invoices/{id}/pdf, QuestPDF behind a swappableIInvoicePdfRenderer); the download is tenant-scoped, so one endpoint safely serves both the operator console and tenant self-service. The dashboard gains a/subscriptionpage (plan, validity, usage, recent invoices), a global expiry/grace warning banner, and invoice detail with PDF download; the admin console gets a PDF button, client-side plan-form validation, and an Adjust validity operator override (POST /tenants/{id}/adjust-validity) that sets a tenant’s expiry directly with no invoice — for comps and corrections. New config keyBilling:ExpiryNotificationLeadDays(default 7). Note: QuestPDF’s Community license is free for organisations under $1M USD/year revenue; larger commercial users must obtain a license — the dependency is isolated behindIInvoicePdfRendererif you prefer to swap it. -
Background-published lifecycle events no longer crash the webhook fan-out (fix). The generic webhook fan-out handles every integration event and reads a tenant-filtered context that captures the ambient tenant at construction — so events published from a background job (no HTTP request) hit a null tenant and threw. Background publishers (the new expiry scan) now install the tenant context before publishing, so the webhook fan-out and email handlers run correctly. The renewal stacking math also now uses the injected clock (was
DateTime.UtcNow), and aX-Subscription-Graceresponse header reports the days left while a tenant is in its grace window. -
Chat delivers messages live to recipients who weren’t in the conversation when they connected — chat broadcasts each message to the channel’s SignalR group, but a connection only joined the groups for channels it already belonged to at connect time (
AppHub.OnConnectedAsync). So a brand-new DM, or being added to a channel mid-session, never received live messages — the recipient saw nothing until they reloaded the page. The hub now exposes a membership-checkedJoinChannelmethod that the dashboard invokes when a conversation is opened and again on reconnect, so a live socket joins the group on demand. Creating a DM also notifies the other participants (via theiruser:{id}group), so the new conversation appears in their channel rail without a refresh. -
Deactivated tenants are now actually blocked (security fix) — deactivating a tenant only flipped an
IsActiveflag in the tenant store; nothing in the auth or request pipeline enforced it, so a deactivated tenant’s users could still log in and use the API. Tenant resolution now rejects requests for a deactivated tenant with403 Forbidden— covering login, token refresh, and every API/realtime request — via a post-authentication guard. Operators (the root tenant) are exempt so they can still manage and reactivate tenants. Deactivation also now invalidates the tenant’s distributed-cache entry, so the change takes effect on the very next request instead of waiting out the 60-minute cache.
2026-05-27
- Dependencies updated to latest for the v10 release — .NET Aspire 13.3.5 (Hosting packages + AppHost SDK), Finbuckle.MultiTenant 10.1.0, MailKit/MimeKit 4.17.0, AWSSDK.S3 4.0.23.4, Scalar.AspNetCore 2.14.14, and SonarAnalyzer 10.27. Builds clean with warnings-as-errors and the full test suite (unit + Testcontainers integration) stays green.
- Template packaging fixes — scaffolded Dockerfiles and dev-machine packing —
dotnet new fsh/fsh newpacked extensionless files (everyDockerfile) to a doubled nested path, so scaffolded projects got aDockerfiledirectory instead of a file anddeploy/docker(docker compose up) was broken. Also made the IDE-cache excludes (.vs/.idea/.vscode) recursive sodotnet packno longer fails (or bundles IDE junk) when packing the template on a developer machine. Scaffolded output now builds and self-hosts cleanly. - Scaffolded apps log in out of the box, get isolated data volumes, and start on
main— threefsh new/ Aspire DX fixes: the AppHost migrator now runsapply --seed, so the root admin (admin@root.com) is seeded automatically — previously a freshly-run app came up with an empty user table and nobody could log in; each app’s Docker volumes are namespaced by app name (e.g.myapp-postgres-data) instead of sharing a literalpostgres-data, so two FSH-based apps on one machine no longer clobber each other’s database; andfsh newinitializes git onmainrather than following the machine’s git default (oftenmaster). - Demo logins (
acme/globex) work on a fresh Aspire launch — the dashboard’s demo-login panel advertised accounts that were never seeded: the AppHost migrator ran onlyapply --seed(which seeds the root admin), while theacme/globexdemo tenants are created by the dev-onlyseed-demoverb. Aspire now runsseed-demoas a dedicated demo-seeder step after migration — soadmin@acme.com/Password123!works the moment the dashboard loads. Also fixes the migrator crashing at startup in Development (its trimmed service graph tripped the DI container’s build-time validation) and corrects the verb’s environment gate toDOTNET_ENVIRONMENT(the migrator is a generic-host console app, not a web host). - Aspire resource names are namespaced per app — the AppHost’s resource/container names (API, migrator, demo-seeder, admin, dashboard) now derive from the app’s namespace, like the Docker volume names already did. A scaffolded
Acme.Storeshowsacme-store-apietc. instead of the kit’s literalfsh-*, so two FSH-based apps on one machine don’t collide. (This repo resolves tofsh-starter-*; thepostgres/redis/minioinfra and thefsh-dbdatabase keep stable names.) - Stale sessions resolve cleanly instead of erroring — both React apps (admin + dashboard) treated an expired token left in
localStorageas signed-in, firing protected requests that 401’d in a loop (SecurityTokenExpiredException). On boot they now attempt one silent token refresh: success restores the session, failure routes to/login. Long-lived sessions still refresh transparently mid-use. - CI split into path-scoped backend + frontend pipelines — the single
ci.ymlis replaced bybackend.yml(runs only onsrc/**changes) andfrontend.yml(runs only onclients/**), so a client-only change never builds or tests the API, and vice versa. The SDK is pinned to the .NET 10 GA release via a rootglobal.json(no more preview channel). Unit and integration tests each run once, and the coverage gate merges their results instead of re-running the whole solution. The React apps get real CI for the first time — ESLint,tsc/Vite build, and the Playwright E2E suites (admin + dashboard) on Node 22. Branch protection requires the always-resolvingBackend CI/Frontend CIgate jobs. See CI/CD. - Consolidated to a single
mainbranch — the repo now uses one long-lived default branch,main; thedevelopbranch is retired. Branch from and targetmain; stable releases are cut fromv*tags. See Contributing. - Removed the redundant root
docker-compose.yml— local development is covered by .NET Aspire and production bydeploy/docker/, so the overlapping root compose file (added 2026-05-24) was dropped. - Missing required request parameters now return
400, not500— calling a tenant-scoped endpoint without thetenantheader (and any other endpoint missing a required header/route/query parameter, or sent with an unreadable/oversized body) raised an ASP.NETBadHttpRequestExceptionthat the global exception handler rendered as a generic500 Internal Server Error. The handler now honours the framework’s own status code, so these surface as a proper400 Bad Request(or413, etc.) with aProblemDetailsbody. Fixes #1245.
2026-05-24
- Cache/store engine switched from Redis to Valkey 8 — the BSD-licensed, Linux Foundation fork of Redis. It’s a drop-in over the Redis protocol (RESP): the
StackExchange.Redisclient and everyCachingOptions:Redisconfig key are unchanged. Applies to .NET Aspire, both Docker Compose files, and the integration-test container. - RedisInsight cache browser is now auto-wired in Aspire, connected to the Valkey instance so you can inspect cache keys, TTLs, and the SignalR backplane in local dev with no manual configuration.
- Docker Compose hardening — the production
deploy/dockerstack now provisions the MinIO bucket before the API starts (fixes a first-uploadNoSuchBucket); the dev rootdocker-compose.ymlnow runs the DB migrator (apply --seed) so the API never boots against an empty schema.