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-09-25

  • Dependencies: .NET Aspire 13.5.4 and every NuGet package to latest. The AppHost SDK and Aspire.Hosting.* move 13.4.0 → 13.5.4 together (mixing 13.4 and 13.5 packages fails at runtime). The .NET 10 platform packages (ASP.NET Core, EF Core, Extensions, SignalR) go 10.0.8 → 10.0.12, OpenTelemetry 1.15 → 1.19, Asp.Versioning 10.2, Npgsql EF 10.0.3, Scalar 2.17, QuestPDF 2026.9, Hangfire 1.8.25, MailKit/MimeKit 4.18, Testcontainers 4.15, and the rest to latest stable. Three majors: StackExchange.Redis 3.3 (the same API as 2.13.17 on a rewritten IO core, now RESP3 by default - Valkey and ElastiCache both speak it; Execute("FLUSHALL")-style admin commands now need AllowAdmin), NSubstitute 6 and xunit.runner.visualstudio 4 (still runs xUnit v2). Microsoft.OpenApi and MessagePack stay on their 2.x lines on purpose. The new SonarAnalyzer adds S8969 (redundant null-forgiving !), which is fatal under warnings-as-errors: the kit’s own code is cleaned up, but if you’ve added code, expect S8969 build errors after pulling - delete the flagged !, and the compiler will tell you if one was actually needed. Asp.Versioning 10.2’s AV0029/AV0030 advisories and Aspire’s ASPIRE010 (CLI bundle) are suppressed; the kit keeps one OpenAPI document per version and runs Aspire via dotnet run. On the first launch Aspire 13.5 recreates the persistent Postgres and Valkey containers; data volumes are kept and the Postgres image stays on 18, so no wipe is needed. See #1396.
  • Identity: password-reset and e-mail-confirmation links now resolve the correct front-end per request (breaking for Production config). Upgrade note - set FrontendOptions:DefaultOrigin before you upgrade, or Production won’t boot. In Production the API now refuses to start when FrontendOptions:DefaultOrigin is missing or not an absolute http(s) URL (Missing required configuration 'FrontendOptions:DefaultOrigin' in Production…), alongside the existing fail-fast on DatabaseOptions:ConnectionString, CachingOptions:Redis and JwtOptions:SigningKey. Point it at your tenant dashboard. The shipped deployment paths set it for you: deploy/docker/docker-compose.yml derives it from FSH_DASHBOARD_URL, and the AWS Terraform stack from dashboard_url (falling back to admin_url) - so the action is for deployments that roll their own hosting. The DbMigrator is unaffected. The reset link was built from a single configured OriginOptions.OriginUrl - which points at the API and ships empty in production, so forgot-password threw Origin URL is not configured - and the confirmation link was built from the request host and pointed straight at the API’s GET /confirm-email route. Neither could target the right SPA when the kit serves more than one front-end (the admin console and the tenant dashboard on different origins). Link resolution now goes through a dedicated FrontendOptions (AllowedOrigins + DefaultOrigin), kept separate from CORS. Self-service flows (forgot-password, self-register) build the link from the request Origin header, validated against FrontendOptions:AllowedOrigins and returned as the canonical entry - so each user gets a link back to the app they started from; because forgot-password is anonymous a forged or unlisted Origin is rejected with 400 once the list is non-empty, and a request with no Origin (curl, mobile, server-to-server) falls back to DefaultOrigin - as does every request while the list is empty, since there is then nothing to validate against. Operator-driven flows (register, resend-confirmation-email) target DefaultOrigin - the recipient’s app - so a tenant user provisioned from the admin console gets a link into the tenant app, not the console. The confirmation e-mail now lands on the SPA /confirm-email page (which then calls the API) instead of the raw API route. Also list every SPA origin in FrontendOptions:AllowedOrigins; both settings ship empty in appsettings.Production.json, and the shipped deploys fill the list from the same SPA URLs (FSH_ADMIN_URL / FSH_DASHBOARD_URL, or the Terraform site URLs - api_extra_cors_origins deliberately stays off it). Outside Production the host still boots without DefaultOrigin and logs one startup Error, but link building has no fallback, so those flows answer 500 until it is set. The two fallback tiers an earlier revision carried were removed on review - the request host is caller-supplied, so a password-reset link built from it delivers a working token to a domain the attacker named, and the API’s own origin returns 404 for the SPA pages these links now target. CorsOptions:AllowedOrigins and OriginOptions:OriginUrl keep their own roles (browser CORS; the API’s public base for avatar URLs). See #1377.
  • Object storage: MinIO replaced with RustFS for local dev, Docker Compose, and integration tests (breaking for docker-compose). The minio/minio and minio/mc images were removed from Docker Hub and quay.io/minio now refuses anonymous pulls, so fresh clones could no longer bring up the stack. The kit now ships RustFS (rustfs/rustfs:1.0.0, S3-compatible, Apache-2.0) on the same ports - 9000 (S3 API) and 9001 (web console) - with bucket bootstrap done by a pinned amazon/aws-cli:2.37.3 init container. Nothing changes in application code: the API still talks to it through the s3 storage provider with ForcePathStyle, and production can keep pointing at AWS S3 or any other S3-compatible store. See PR #1390.
    • Aspire: the minio / minio-init resources are now rustfs / rustfs-init, and the AppHost parameters are renamed minio-user / minio-password → rustfs-user / rustfs-password (default rustfsadmin). If you set the old parameters in user secrets or config, rename them. The data volume is now {appPrefix}-rustfs-data, so local uploads start empty; delete the old *-minio-data volume when you no longer need it.
    • Breaking (docker-compose): in deploy/docker/.env, rename MINIO_ROOT_USER / MINIO_ROOT_PASSWORD → RUSTFS_ACCESS_KEY / RUSTFS_SECRET_KEY (compose refuses to start without them). The services are now rustfs / rustfs-init and the volume minio_data → rustfs_data, so existing objects are not carried over: before upgrading, copy them out of the old MinIO bucket and into the new fsh bucket with aws s3 sync (against each --endpoint-url), or have users re-upload. fsh new now generates RUSTFS_ACCESS_KEY / RUSTFS_SECRET_KEY for new projects.
    • Integration tests: FshWebApplicationFactory runs RustFS through the generic Testcontainers ContainerBuilder (the Testcontainers.Minio package is gone; the base Testcontainers package is added), waiting on /health. Its public members are renamed Minio* → S3AccessKey / S3SecretKey / S3Bucket / S3ServiceUrl; update any custom tests that used MinioServiceUrl.
  • Idempotency: replay actually engages now, and replays the right thing (fix). .WithIdempotency() probed the response cache through IDistributedCache on the raw key but stored through HybridCache, which keys its backing entries under its own scheme - so the probe never found what the store wrote and replay silently never happened, in production as much as in tests. Both sides now use the same store, key and serializer. Three defects hiding behind that are fixed with it: the cached payload was the serialized Ok<T>/Created<T> wrapper ({"value":{…},"statusCode":200}) rather than the wire DTO, and the status was read before the result had run, so a 201 replayed as 200; concurrent requests carrying the same key both executed the handler; and the response was stored on the client’s cancellation token after the body had gone to the client, so the timeout-then-retry that idempotency exists to absorb found nothing cached and ran the handler a second time. The store now happens before the client write and on a token that cannot be cancelled, and the handler’s result is captured with the abort token detached - ASP.NET swallows the cancellation inside WriteAsJsonAsync, which otherwise captured, cached and replayed an empty body for 24 h. An endpoint can also cap its own per-endpoint replay window with WithIdempotency(ttl): RequestUploadUrl returns a presigned URL good for fifteen minutes and was cached for the twenty-four-hour default, so a retry an hour later replayed a 200 carrying a dead URL. See Idempotency.
  • Idempotency: replayed responses keep Location and ETag. Executing an IResult is exactly when those headers get set, and the cached entry carried only status, content type and body - so a created resource replayed as a bare 201 with nothing pointing at it, breaking any client that follows the header, and only on the retry path nobody tests. An allow-list (Location, ETag) is now captured and replayed; transport and host-owned headers deliberately are not.
  • Idempotency: the cache key is now scoped to the operation, not just the tenant (fix). The entry was keyed on tenant + key alone, so one key reused against a second idempotent endpoint replayed the first endpoint’s response and the second request silently never ran - across 29 endpoints in eight modules, one of them anonymous (self-registration, which has no tenant claim and therefore lands in the shared "global" bucket). The key now folds in the HTTP method and route pattern. This was latent only because replay never engaged; the same change that makes replay work is what would have put it on the wire.
  • Idempotency: the reservation is now sound under the races it exists for (fix). Four defects in the in-flight lock, each of which let a duplicate execute the handler a second time or locked a caller out of a key. The entry is keyed on the resolved tenant rather than the caller’s tenant claim, so a root operator acting on two tenants with one key no longer shares a single "root" bucket - and an unresolved, caller-supplied tenant header is never used to build a key. The cache is probed again once the lock is held, because the original request can store its response and release in the window between the first probe and the reservation. The lock lives under its own key prefix instead of a :inflight suffix on the entry key - one request with a key ending in :inflight could otherwise park a 24 h entry exactly where another key’s lock goes, 409-ing that key for a whole day. Releasing is a compare-and-delete against the token the reservation was taken with, so a request that failed open, or one whose reservation already expired, cannot free a lock another request still owns. The in-process fallback now expires on ReservationTtl like the Redis branch instead of stranding a key until the process restarts.
  • Idempotency: an idempotent handler now survives a client disconnect (fix). The handler ran under the client’s abort token, so a client hanging up right after the side effect committed cancelled whatever the handler awaited next - an EF read, an outbox write, a Mediator behaviour - and the filter was left with nothing to store: the retry re-executed the side effect, which is the exact duplicate the feature exists to absorb. The handler now runs with that token detached, so on an idempotent endpoint a disconnect no longer aborts it (keep those handlers short, and don’t put .WithIdempotency() on a streaming or large-file endpoint - the response is buffered to be captured). Four smaller holes went with it: the cache probe was the one link that still hard-failed, so a Redis blip 500’d every idempotent endpoint for exactly the clients that send a key - it now fails open as a miss like the reservation and the store; a handler that writes to HttpContext.Response itself is passed through instead of having an empty capture stored and its status set after the response started; the key folds in the resolved route values, so PUT /tickets/1 and PUT /tickets/2 are no longer one operation; and the key folds in the caller, so two users of one tenant reusing a low-entropy key no longer receive each other’s response bodies. The 409 now carries Retry-After: 1, and IdempotencyOptions is validated at startup - a zero DefaultTtl used to throw inside the best-effort store, log a warning and carry on, so nothing was ever cached and replay never engaged.
  • Idempotency: self-registration is no longer idempotent, and no anonymous endpoint can be (fix). /self-register is anonymous, and there is no user to scope the cache key by, so every unauthenticated caller resolves to the same anon caller: two people registering on one tenant with the same low-entropy key ("1", "retry") built the identical key, and the second replayed the first registrant’s 201 while their own account was silently never created. Reachable only once replay started engaging, which is what the rest of this entry did. .WithIdempotency() is off that endpoint - a genuine retry there is already safe, the unique-email constraint rejects the duplicate - and WithIdempotency() now marks the endpoint with IdempotentEndpointMetadata so an integration test can walk the endpoint map and fail the build if an AllowAnonymous() endpoint ever carries it again.
  • Idempotency: only 2xx is stored, and a duplicate still in flight gets 409. A failure is not a record of a committed side effect, and storing it locked the caller out of that key for the full 24 h TTL after a transient downstream error. Concurrent duplicates are serialized by an atomic in-flight reservation (Redis SET NX, else in-process) under a new IdempotencyOptions.ReservationTtl (default 1 minute), deliberately decoupled from the response TTL - keying the lock to 24 h would strand it for a day if the process died mid-request. The duplicate that loses the race re-probes once, and otherwise receives 409 Conflict. Reserve and release both fail open, and the 409 and the over-long-key 400 are now RFC 9457 ProblemDetails like every other error on these endpoints.
  • CORS: the restricted policy now allows PATCH and every header the two React apps send (fix). With CorsOptions:AllowAll=false - the shipped appsettings.json and appsettings.Production.json - the browser rejected preflights from both apps, because AllowedHeaders was only content-type, authorization and AllowedMethods lacked PATCH. Dev hid it (appsettings.Development.json sets AllowAll: true). The shipped lists now add tenant (every call), x-fsh-app (login), idempotency-key (chat send), and x-requested-with / x-signalr-user-agent (SignalR negotiate), plus PATCH. Upgrade note: if you override CorsOptions:AllowedHeaders or CorsOptions:AllowedMethods in your own config, add these too, or login, realtime and PATCH calls fail under restricted CORS. See CORS & headers and #1391.
  • Auditing: entity-change diffs no longer store secrets (security fix). The entity diff flagged sensitive properties as IsSensitive but still stored their raw old and new values, so PasswordHash, security stamps and token values landed in AuditRecords in clear text. Any property whose name contains password, secret, token, apikey, connectionstring or securitystamp is now masked to "****" (a null stays null). Rows written before the upgrade are not rewritten - purge or scrub them if that matters for you. A new IAuditExempt marker in Modules.Auditing.Contracts skips an entity in entity-change auditing entirely; [NoAudit] still only affects HTTP activity auditing. See Auditing and #1392.
  • Forwarded headers: TrustedProxyOptions:ForwardLimit below 1 is rejected at startup (fix). It was passed straight to ForwardedHeadersOptions: 0 silently left forwarded headers unprocessed, and a negative value made every request fail with 500. Both now fail the host build with a message naming the setting. See Reverse proxy & forwarded headers and #1386.
  • Mailing: one HTML shell and one encoder for every module. New HtmlEmail helper in FSH.Framework.Mailing - Encode, Shell, LinkAction, Notice - replaces Identity’s internal EmailBodies and the billing e-mails’ own wrapper. Billing e-mails (invoice issued, nearing expiry, grace, expired) now render in the shared card and escape values with full HTML encoding (quotes and apostrophes included, and the invoice amount’s currency) instead of a hand-rolled & / < / > replace. The confirmation e-mail still builds its own document for now. See Mailing and #1385.
  • Frontend: no more idle GPU load from the background (fix). Both apps animated the body background glows forever (fsh-aurora, a background-position drift that can’t be composited), repainting the full viewport and its blur/blend layers every frame even when the page sat idle. The glows are now static at the same position. See #1394 and #1368.
  • Identity: two people editing the same profile no longer silently overwrite each other (fix). PUT /api/v1/identity/profile replaces the whole representation and carried no concurrency token, so overlapping saves resolved as last-write-wins with the losing change gone and no error raised anywhere - and because both front-ends compensate with a client-side read-modify-write, a save built on a stale read confidently echoed every old field back over the newer write. GET /profile now returns the user’s ConcurrencyStamp as a strong ETag, and PUT /profile honours If-Match: a stale token is answered with 412 Precondition Failed and nothing is written. The header is optional, so existing callers are unaffected; If-Match: * is accepted, a weak validator never matches (If-Match mandates strong comparison), and a malformed header is a 400 rather than a 412 that would trap a client in an unwinnable retry loop. The precondition runs before any storage call, so a rejected update never orphans an uploaded avatar nor clears the current one. Two smaller fixes came with it: Identity’s own ConcurrencyFailure result, previously surfacing as a generic 500, now maps to the same 412, and the sign-in refresh no longer runs when the update failed. No migration - AspNetUsers.ConcurrencyStamp was already an EF Core concurrency token. The tenant dashboard sends the tag from the read that seeded the form and deliberately does not auto-retry on 412: resending the body built from the values the user saw, against a freshly fetched tag, performs the very overwrite the 412 rejected. It keeps the edits on screen, adopts the current version, and asks for a deliberate re-save. See Identity.
  • CORS: the framework now exposes ETag and allows If-Match. An ETag/If-Match contract is invisible to a browser on another origin unless the server says so: ETag is not a CORS-safelisted response header, and the kit’s policy never called WithExposedHeaders, so a front-end read null and silently stopped sending the precondition. The policy now exposes ETag on both the permissive and restricted branches, and if-match ships in CorsOptions.AllowedHeaders in appsettings.json and appsettings.Production.json. If you replace the policy with your own, keep both, otherwise the profile precondition above degrades back to a lost update with no error to notice.

2026-08-07

The transactional outbox was rebuilt so that any module can publish, every tenant’s events actually get dispatched, and the kit is safe to scale past one API instance.

  • Eventing: the outbox is now framework-owned, and a second module can finally publish (fix, breaking). AddEventingForDbContext<TDbContext> registered non-keyed IOutboxStore/IInboxStore once per module DbContext, so .NET DI resolved whichever module registered last for the whole application - and only IdentityDbContext mapped the tables. A second module calling it silently repointed every module’s outbox, including Identity’s working one, at a context with no OutboxMessages table. The tables have moved out of identity into a new framework schema owned by a framework EventingDbContext; with one owner, both stores collapse to a single unambiguous registration and AddEventingForDbContext<T> is removed. Modules now inject IOutboxWriter (new, in Eventing.Abstractions) and publish - no per-module registration of any kind. Because EventingDbContext derives from BaseDbContext, a tenant with a dedicated database gets its outbox rows in that database. Upgrading: run the migrator (DbMigrator apply); the migration copies existing outbox and inbox rows into the new schema before dropping the old tables, so pending events and the idempotency ledger survive. If you called AddEventingForDbContext<T> in your own module, delete the call. Reported as #1349.

  • Eventing: a tenant with a dedicated database now has its outbox dispatched at all (fix). Outbox rows written in a tenant-scoped request land in that tenant’s database, but the dispatcher ran with no tenant context and polled only the default connection - so on a database-per-tenant deployment those events were never delivered. The dispatcher now enumerates drain targets (the default database plus one per distinct active per-tenant connection string, deduped) and drains each under the right tenant context. One unreachable tenant database is logged and skipped instead of stalling the cycle. Single-database deployments behave exactly as before.

  • Eventing: multiple API instances no longer double-publish every event (fix). There was no row-level claim, so two dispatchers draining concurrently both picked up the same rows. Rows are now leased with Postgres FOR UPDATE SKIP LOCKED in a single statement, so instances partition a batch. The lease is an expiry (EventingOptions:OutboxClaimLeaseSeconds, default 300) - a dispatcher that dies mid-batch has its rows recovered rather than stranded. Set it above your worst-case batch publish time, or a second instance re-claims rows still in flight. Providers without SKIP LOCKED fall back to the old unclaimed read and log a warning that only one instance is safe.

  • Eventing: the outbox write is now genuinely transactional. It used to commit on its own, so a business write that later rolled back still published its event, and a crash between the two lost it. Every DbContext in a DI scope now shares one DbConnection (IScopedDbConnectionProvider), which is the only way EF Core can enlist a second context in a transaction another one opened, and the outbox write joins the caller’s transaction when there is one. With no transaction open, behaviour is unchanged.

  • Eventing: seven direct IEventBus publishes moved onto the outbox (behaviour change). Billing invoice issuance, Files finalize-upload, Multitenancy tenant create / renew / expiry notice, and Identity user-registered are now durable and crash-safe - and therefore asynchronous: the consumer runs on the next dispatch cycle rather than inside the request. Creating a tenant returns before its subscription and invoice exist; tune OutboxDispatchIntervalSeconds (default 10) if that window matters to you. Chat mention notifications deliberately stay on the bus, since the handler pushes over SignalR and a delayed badge reads as broken.

  • The real client IP now reaches the pipeline behind a reverse proxy (security fix). UseHeroPlatform never called UseForwardedHeaders, so behind an ingress (cloudflared → Caddy → app) Connection.RemoteIpAddress was always the proxy’s container IP. Two consequences: the IP-partitioned rate limiters (the anonymous auth policy and the global IP limiter) collapsed into a single shared bucket - one anonymous spike throttled every tenant’s login, and per-origin brute-force protection was gone - and audit / UserSession IPs recorded the proxy for every request. The kit now honours X-Forwarded-For / X-Forwarded-Proto, applied first in the pipeline so rate limiting, auth, HTTPS redirect, and audit all see the real client. Trust is bound to a new TrustedProxyOptions section (KnownProxies, KnownNetworks CIDRs, ForwardLimit): forwarded headers are honoured only from the configured ingress, so a client reaching the app directly can’t forge its IP or scheme. Action required behind a proxy: set TrustedProxyOptions to your ingress CIDR(s) and hop count - with nothing configured the framework default (loopback only) stands and forwarded headers are ignored. ForwardLimit must be at least 1; a lower value is rejected at startup, since 0 would leave forwarded headers unprocessed with no error and a negative value would fail every request. See CORS & security headers and the production checklist.

  • Dashboard: tenants can now edit their own branding from Settings. A new Settings → Branding tab lets a tenant admin holding Tenants.UpdateTheme customise their light and dark palettes and brand asset URLs (logo, dark-mode logo, favicon) with a live preview - mirroring the operator’s existing tenant-branding card, but self-service and with no tenant: header, since the theme endpoints are already scoped to the current tenant. The tab renders only for holders of that permission; a direct-URL visit without it hits the API’s 403, surfaced as an error band. Editing is draft-based - a Reset to defaults action and per-palette reset are available, and unsaved edits are preserved while you work (a co-admin’s concurrent change appears on a manual refresh rather than overwriting your form).

  • The fsh CLI and dotnet new template are now on NuGet as stable 10.0.0. The two distribution packages that 10.0.0 had been waiting on have shipped: FullStackHero.CLI (install with dotnet tool install -g FullStackHero.CLI - no more --prerelease) and FullStackHero.NET.StarterKit (dotnet new install FullStackHero.NET.StarterKit). Because fsh new scaffolds from that template, the one-command flow is now end-to-end: dotnet tool install -g FullStackHero.CLI && fsh new MyApp produces a fully renamed project - unique JWT signing key, generated Docker secrets, npm install run, initial commit on main. The Install and CLI pages now lead with the CLI as the recommended path; git clone and the GitHub template remain available for reading the source or zero-install runs. See the 10.0.0 release.

2026-08-06

  • Mailing: e-mails now carry real HTML plus a plain-text alternative, so the password-reset link is clickable again (fix). Every provider puts MailRequest.Body in the HTML part (MailKit’s BodyBuilder.HtmlBody, SendGrid’s htmlContent), but the password-reset and welcome mails passed bare text into it. A plain URL inside an HTML part is not auto-linked by most clients - auto-linking is text/plain behaviour - so the reset link arrived as dead text and the user could not finish the flow. The welcome mail also interpolated the user-supplied first name straight into that markup, and SendGrid was handed Body as both parts, shipping raw markup to text-only clients. MailRequest gains an optional TextBody for the text/plain alternative: SmtpMailService emits both parts as multipart/alternative, and SendGridMailService maps them separately onto plainTextContent/htmlContent. Identity builds its bodies through a new EmailBodies helper that renders the action link as a real <a href> and HTML-encodes every interpolated value; the four tenant billing mails gained their plain twin, so no message goes out HTML-only. Action for deployments: none - TextBody is optional and appended last, so existing callers keep compiling. If you send mail from your own code, put HTML in Body and the plain wording in TextBody; a bare URL in Body will not be clickable. See #1351.

2026-07-11

A security & reliability audit pass across the backend. Every finding was reproduced with a failing test and adversarially verified before fixing; the suite stays green (warnings-as-errors, Testcontainers integration tests).

  • Webhooks: outbound delivery now blocks SSRF targets (security fix). A subscription’s destination URL is tenant-supplied, but the create validator only checked “absolute URI + http/https scheme” and both delivery sinks POSTed to it with redirects allowed and no address screening. Any tenant with Webhooks.Create could point a webhook at 169.254.169.254 (cloud instance metadata), loopback, or an RFC1918 host and read the delivery log as a blind-SSRF oracle. A shared guard now rejects loopback / link-local / private / CGNAT / IPv6-ULA / localhost targets at create time, and the Webhooks HTTP client screens the resolved IP at connect time (defeating DNS rebinding) with redirects disabled. See Webhooks.
  • Mailing: a rejected SendGrid send no longer looks like success (fix). SendGridMailService discarded the Response from SendEmailAsync, and the client runs with HttpErrorAsException=false, so a non-2xx reply (invalid API key, rejected recipient, rate limit) returned normally. Every caller - auth e-mail links, webhooks, notifications - treated a failed send as delivered. A non-success status now throws so the failure reaches the global error handler.
  • Auditing: the security/exception audit list endpoints are now bounded (fix). GET /api/v1/audits/security and /audits/exception did ToListAsync with no paging and no default window, so a no-argument call materialized a tenant’s entire audit history - a latent out-of-memory on an active tenant. Both now accept optional Skip/Take and cap server-side (page size 1-200, default 50), mirroring the session-list convention. The unified GET /api/v1/audits/ endpoint the console uses was already bounded and is unaffected.
  • Eventing: failed outbox messages now back off, and dead-letters are recoverable and observable (fix). A failing outbox message was retried on every dispatch cycle (~10s) with no backoff, dead-lettered after 5 attempts in ~50s, and then lost - no replay path, no metric. Retries now use exponential backoff (NextRetryAt, base 30s capped at 1h, configurable via EventingOptions); IOutboxStore gains GetDeadLetteredAsync / RedriveDeadLettersAsync to inspect and reset dead-lettered messages; and a new OpenTelemetry meter FSH.Eventing exposes outbox.deadlettered / outbox.redriven counters so the condition is alertable. Additive migration (nullable NextRetryAt).

2026-06-24

  • Config: the tenant billing grace-period setting was renamed Billing:GraceWindowDays → Billing:GracePeriodDays (breaking). The option (bound in both TenantBillingOptions and Identity’s TenantGraceOptions) is now named consistently with the “grace period” term used everywhere else in the kit; the default of 7 days is unchanged. Deployments that set Billing:GraceWindowDays (env Billing__GraceWindowDays) must rename the key to Billing:GracePeriodDays, otherwise the override is silently ignored and the default applies. No public contract change - the GraceEndsUtc tenant-status and integration-event fields are untouched. See PR #1313.

2026-06-12

  • Both apps: every nav surface is now permission-gated. A tenant user whose role lacked a module’s permission could still see and open its nav entry, landing on a guaranteed 403 - e.g. the dashboard Files page rendering a ForbiddenException error band for a user without Files.Upload. The dashboard sidebar now gates Chat, My Files, Subscription, Invoices, the Catalog pages, and Tickets on the same permission each page’s API enforces (joining the already-gated Identity/Audits/Sessions/Trash entries), and the command palette - previously a second, fully ungated surface - applies the same gates to its Navigate and Create actions. In the admin console, the Webhooks nav entry and routes gained the Webhooks.View gate the API has required since the webhook permission hardening, and the Role editor’s permission catalog now includes the Webhooks group so those permissions can actually be granted. Server-side enforcement is unchanged - this is the UX layer catching up.
  • Demo seed: the acme Manager role’s catalog permissions now actually work (fix). The seeded claims used permission names that don’t exist (Permissions.Brands.View instead of the real Permissions.Catalog.Brands.View, and likewise for Categories/Products), so the “full catalog” Manager demo user silently had no catalog access at all. The seeder now references the module permission constants directly, so the names can’t drift again. Existing databases: re-run the migrator’s seed-demo verb to add the corrected claims (idempotent), and have demo users sign in again to pick them up.

2026-06-11

  • Identity: roles granted through groups now confer their permissions (fix). A user group can carry role assignments, and the issued JWT already listed those group-derived roles as role claims - but the server-side permission resolver only counted roles assigned to the user directly. So a user whose only role came via a group failed every permission-gated endpoint with 403 even though their token showed the role, and their effective-permission list came back empty. The resolver now unions direct and group-derived roles, matching what token issuance and the permission-cache invalidation (which already fired on every group change) always assumed. No action required - the fix takes effect on each user’s next permission-cache load.

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 packing - dotnet 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.