Skip to content
fullstackhero

Reference

Overview

Release notes and version history for fullstackhero.

views 0 Last updated

Notable changes to the kit, newest first.

2026-06-09

  • Dashboard: revoking an impersonation grant now ends the session gracefully instead of stranding the impersonated view. When an operator revoked (or let expire) an in-progress impersonation from the admin console, the dashboard kept the now-dead impersonation token installed: every request 401’d, but the page just rendered a stuck red error band under a half-loaded surface while the “End impersonation” banner lingered — a confusing, broken-looking state. The dashboard now detects this case (a 401 while the active token carries the act_sub impersonation claim — a durable signal that works even though production keeps the 401 body opaque) and routes to a calm full-screen Impersonation ended page that explains the access was revoked or expired and offers a single Back to sign in action, which clears the dead token. Mirrors the existing deactivated-tenant terminal page.
  • Dashboard: the Recycle bin only shows the sections you can restore. The Trash page’s five tabs (Products, Brands, Categories, Tickets, Files) were hard-coded with no permission check, so a user with rights to restore some resources but not others could click a tab and hit a 403 error band — e.g. a tenant member without Files access opening the Files tab. Each tab is now gated on the permission its endpoint enforces (the resource’s Restore, or Files.ViewTrash), tabs the user can’t reach are hidden, and the active tab falls back to the first visible one. The Trash sidebar entry itself hides when the user can reach none of them, and a direct visit shows a “no recycle bins available” state instead of a wall of 403s. The server still enforces every permission — this is the UX layer catching up to the existing nav-gating pattern.
  • Realtime: the dashboard’s live feed connects instantly instead of sitting on “Connecting” for up to 15s. The SSE stream handler set its response headers and then waited for the first event or heartbeat before writing anything; Kestrel buffers response headers until the first body write, and the browser’s fetch() only resolves once those headers arrive — so on an idle stream the System-status card showed “Connecting” for up to the 15s heartbeat interval on every connect and reconnect. The handler now flushes an initial no-op SSE comment immediately, so the client flips to “Connected” at once.
  • Template package: ships only tracked source — no more 583 MB package or leaked Terraform state (fix). The dotnet new fsh template was packed by globbing the whole working tree (..\**\* with a denylist), which swept in any local artifact the denylist happened to miss. The result was a 583 MB NuGet package that bundled deploy/**/.terraform provider binaries, *.tfstate files (your infrastructure state — a secret leak), tens of MB of audit-dlq runtime dumps, and a stray local release-nupkgs output dir. Packing is now driven by a git-tracked allowlist — an MSBuild target enumerates git ls-files and ships only committed files — so nothing local or gitignored can ever leak into the package again. The package drops to 2.9 MB / 2057 files, the NU5123 long-path warnings are gone, and the scaffolded output is unchanged. This affects only the published template package, not what you get from a freshly scaffolded project.

2026-06-06

  • Realtime: a client disconnecting mid-connect no longer logs a spurious error. When a SignalR connection dropped while AppHub.OnConnectedAsync was still wiring it up — a fast reconnect, a page navigation, or ordinary negotiate/connect churn — the aborting connection token cancelled the in-flight channel lookup, and the resulting OperationCanceledException surfaced as an Error when dispatching 'OnConnectedAsync' on hub log line with a full EF/Npgsql stack trace. Nothing was actually wrong: there was simply no connection left to set up. The hub now swallows cancellation caused by the connection aborting, so these benign disconnects stay out of the error log. Genuine faults still propagate and log as before.

2026-06-04

  • .NET Aspire updated to 13.4.0 — the Hosting packages (Aspire.Hosting.JavaScript / PostgreSQL / Redis) and the AppHost SDK move 13.3.5 → 13.4.0. StackExchange.Redis is bumped 2.11.0 → 2.13.17 because Aspire.Hosting.Redis 13.4.0 requires ≥ 2.13.1 (a NU1109 downgrade otherwise). Builds clean with warnings-as-errors; the full suite (unit + Testcontainers integration, incl. the Valkey-backed tests) stays green.
  • Action required for existing local dev: wipe your Postgres data volume. Aspire 13.4.0’s default Postgres container image moves to major 18, which changed the on-disk data layout (version-specific subdirectories under /var/lib/postgresql). Postgres 18 refuses to start on a *-postgres-data volume written by an older Postgres, failing with PostgreSQL data in: /var/lib/postgresql. This affects local Aspire dev only — production deploy stacks pin their own Postgres and are unaffected. Fix: remove the stale volume and relaunch; Aspire re-runs apply --seed + seed-demo to rebuild and reseed it.
    Terminal window
    docker volume rm fsh-starter-postgres-data # use your app's prefix; e.g. acme-store-postgres-data
    dotnet run --project src/Host/FSH.Starter.AppHost

2026-06-01

Post-10.0.0 pre-release hardening from a module-by-module audit (correctness, security, completeness, test coverage) across the backend. Every finding was adversarially verified before fixing; the suite stays green (warnings-as-errors, Testcontainers integration tests).

  • Chat: restoring an archived channel no longer loses its members (fix). Archiving a channel called db.Remove(channel), which cascaded the delete onto the ChannelMember rows; because the soft-delete interceptor only rescues owned references, the members were hard-deleted and a later restore brought back an empty channel — the creator then got 404 trying to post. Archiving is now an explicit domain state change that flips the soft-delete flag and leaves membership intact, so a restore is lossless.
  • Tickets: the lifecycle and permission set are now complete. The Closed state was unreachable (no way to get there) and the Tickets.Update / Tickets.Delete permissions were registered with no endpoints behind them — so an admin could grant rights that did nothing, and the existing trash/restore had no way to actually trash a ticket. This adds first-class Close (POST /api/v1/tickets/{id}/close, Resolved → Closed), Update (PUT /api/v1/tickets/{id} — edit title/description/priority; frozen once Closed), and Delete (DELETE /api/v1/tickets/{id} — soft-delete; comments survive and return on restore), each with a validator, a permission gate, and a new Tickets.Close permission. GET /api/v1/tickets/{id}/comments now returns 404 for a non-existent ticket instead of a misleading empty list. See Tickets.
  • Webhooks: signing secrets are encrypted at rest, and the endpoints are permission-gated (security fixes). The HMAC signing secret was stored as plaintext in a field named SecretHash — a database breach would have exposed every tenant’s secret. The secret is the HMAC key (it must stay recoverable, so hashing isn’t an option), so it is now encrypted with ASP.NET Data Protection on create and decrypted only at sign time. Separately, the endpoints were authentication-only — any signed-in user could manage every webhook in their tenant; they now require the new Webhooks.View / Create / Delete / Test permissions. After upgrading, grant these to the roles that manage webhooks or those users will get 403. See Webhooks.
  • Multitenancy & correctness. The GET tenant provisioning status and retry provisioning endpoints now accept and forward a CancellationToken (graceful shutdown); the Notifications mention handler fails loud on a tenant-context mismatch rather than risking a cross-tenant write; webhook list endpoints validate pagination (a pageSize=0 previously surfaced as a 500, now a clean 400); and the Billing monthly-invoice job takes its clock from TimeProvider for deterministic tests.
  • Known follow-up. Billing publishes its InvoiceIssued event directly on the in-memory bus rather than through the transactional outbox (Files does the same, with no consumer yet). Closing this needs a small Eventing building-block change to support more than one outbox-backed module; tracked for a follow-up release. The in-memory path is correct today.

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.