Skip to content
fullstackhero

Reference

Overview

Release notes and version history for fullstackhero.

views 0 Last updated

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 fsh CLI gained a --version flag and a corrected (semver-aware) update check; the unimplemented --db sqlserver scaffold 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 BillingDbContext is 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 basic Billing.View/Billing.Manage permissions) could read, issue, pay, or void another tenant’s invoices by id, reassign or cancel another tenant’s subscription via a body tenantId, 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 — and POST /api/v1/billing/invoices/generate is now operator-only. Separately, a role-permission filter only stripped a Permissions.Root. name prefix that matches no real operator permission, so a non-root tenant admin with Roles.Update could grant their own role the operator-only Tenants.* / Platform.* permissions and escalate to managing every tenant; the filter now keys off the registered IsRoot flag. 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. (The tenant-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 global JsonStringEnumConverter; 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’s status serialized as 0 and 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 403 on 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 swappable IInvoicePdfRenderer); the download is tenant-scoped, so one endpoint safely serves both the operator console and tenant self-service. The dashboard gains a /subscription page (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 key Billing: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 behind IInvoicePdfRenderer if 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 a X-Subscription-Grace response 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-checked JoinChannel method 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 their user:{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 IsActive flag 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 with 403 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 packingdotnet new fsh / fsh new packed extensionless files (every Dockerfile) to a doubled nested path, so scaffolded projects got a Dockerfile directory instead of a file and deploy/docker (docker compose up) was broken. Also made the IDE-cache excludes (.vs/.idea/.vscode) recursive so dotnet pack no 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 — three fsh new / Aspire DX fixes: the AppHost migrator now runs apply --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 literal postgres-data, so two FSH-based apps on one machine no longer clobber each other’s database; and fsh new initializes git on main rather than following the machine’s git default (often master).
  • 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 only apply --seed (which seeds the root admin), while the acme/globex demo tenants are created by the dev-only seed-demo verb. Aspire now runs seed-demo as a dedicated demo-seeder step after migration — so admin@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 to DOTNET_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.Store shows acme-store-api etc. instead of the kit’s literal fsh-*, so two FSH-based apps on one machine don’t collide. (This repo resolves to fsh-starter-*; the postgres/redis/minio infra and the fsh-db database keep stable names.)
  • Stale sessions resolve cleanly instead of erroring — both React apps (admin + dashboard) treated an expired token left in localStorage as 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.yml is replaced by backend.yml (runs only on src/** changes) and frontend.yml (runs only on clients/**), 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 root global.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-resolving Backend CI / Frontend CI gate jobs. See CI/CD.
  • Consolidated to a single main branch — the repo now uses one long-lived default branch, main; the develop branch is retired. Branch from and target main; stable releases are cut from v* tags. See Contributing.
  • Removed the redundant root docker-compose.yml — local development is covered by .NET Aspire and production by deploy/docker/, so the overlapping root compose file (added 2026-05-24) was dropped.
  • Missing required request parameters now return 400, not 500 — calling a tenant-scoped endpoint without the tenant header (and any other endpoint missing a required header/route/query parameter, or sent with an unreadable/oversized body) raised an ASP.NET BadHttpRequestException that the global exception handler rendered as a generic 500 Internal Server Error. The handler now honours the framework’s own status code, so these surface as a proper 400 Bad Request (or 413, etc.) with a ProblemDetails body. 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.Redis client and every CachingOptions:Redis config 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/docker stack now provisions the MinIO bucket before the API starts (fixes a first-upload NoSuchBucket); the dev root docker-compose.yml now runs the DB migrator (apply --seed) so the API never boots against an empty schema.