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 needAllowAdmin), NSubstitute 6 and xunit.runner.visualstudio 4 (still runs xUnit v2).Microsoft.OpenApiandMessagePackstay 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’sAV0029/AV0030advisories and Aspire’sASPIRE010(CLI bundle) are suppressed; the kit keeps one OpenAPI document per version and runs Aspire viadotnet 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:DefaultOriginbefore you upgrade, or Production won’t boot. InProductionthe API now refuses to start whenFrontendOptions:DefaultOriginis missing or not an absolutehttp(s)URL (Missing required configuration 'FrontendOptions:DefaultOrigin' in Production…), alongside the existing fail-fast onDatabaseOptions:ConnectionString,CachingOptions:RedisandJwtOptions:SigningKey. Point it at your tenant dashboard. The shipped deployment paths set it for you:deploy/docker/docker-compose.ymlderives it fromFSH_DASHBOARD_URL, and the AWS Terraform stack fromdashboard_url(falling back toadmin_url) - so the action is for deployments that roll their own hosting. The DbMigrator is unaffected. The reset link was built from a single configuredOriginOptions.OriginUrl- which points at the API and ships empty in production, soforgot-passwordthrewOrigin URL is not configured- and the confirmation link was built from the request host and pointed straight at the API’sGET /confirm-emailroute. 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 dedicatedFrontendOptions(AllowedOrigins+DefaultOrigin), kept separate from CORS. Self-service flows (forgot-password,self-register) build the link from the requestOriginheader, validated againstFrontendOptions:AllowedOriginsand 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 unlistedOriginis rejected with400once the list is non-empty, and a request with noOrigin(curl, mobile, server-to-server) falls back toDefaultOrigin- as does every request while the list is empty, since there is then nothing to validate against. Operator-driven flows (register,resend-confirmation-email) targetDefaultOrigin- 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-emailpage (which then calls the API) instead of the raw API route. Also list every SPA origin inFrontendOptions:AllowedOrigins; both settings ship empty inappsettings.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_originsdeliberately stays off it). Outside Production the host still boots withoutDefaultOriginand logs one startupError, but link building has no fallback, so those flows answer500until 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 returns404for the SPA pages these links now target.CorsOptions:AllowedOriginsandOriginOptions:OriginUrlkeep 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/minioandminio/mcimages were removed from Docker Hub andquay.io/minionow 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 pinnedamazon/aws-cli:2.37.3init container. Nothing changes in application code: the API still talks to it through thes3storage provider withForcePathStyle, and production can keep pointing at AWS S3 or any other S3-compatible store. See PR #1390.- Aspire: the
minio/minio-initresources are nowrustfs/rustfs-init, and the AppHost parameters are renamedminio-user/minio-password→rustfs-user/rustfs-password(defaultrustfsadmin). 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-datavolume when you no longer need it. - Breaking (docker-compose): in
deploy/docker/.env, renameMINIO_ROOT_USER/MINIO_ROOT_PASSWORD→RUSTFS_ACCESS_KEY/RUSTFS_SECRET_KEY(compose refuses to start without them). The services are nowrustfs/rustfs-initand the volumeminio_data→rustfs_data, so existing objects are not carried over: before upgrading, copy them out of the old MinIO bucket and into the newfshbucket withaws s3 sync(against each--endpoint-url), or have users re-upload.fsh newnow generatesRUSTFS_ACCESS_KEY/RUSTFS_SECRET_KEYfor new projects. - Integration tests:
FshWebApplicationFactoryruns RustFS through the generic TestcontainersContainerBuilder(theTestcontainers.Miniopackage is gone; the baseTestcontainerspackage is added), waiting on/health. Its public members are renamedMinio*→S3AccessKey/S3SecretKey/S3Bucket/S3ServiceUrl; update any custom tests that usedMinioServiceUrl.
- Aspire: the
- Idempotency: replay actually engages now, and replays the right thing (fix).
.WithIdempotency()probed the response cache throughIDistributedCacheon the raw key but stored throughHybridCache, 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 serializedOk<T>/Created<T>wrapper ({"value":{…},"statusCode":200}) rather than the wire DTO, and the status was read before the result had run, so a201replayed as200; 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 insideWriteAsJsonAsync, which otherwise captured, cached and replayed an empty body for 24 h. An endpoint can also cap its own per-endpoint replay window withWithIdempotency(ttl):RequestUploadUrlreturns a presigned URL good for fifteen minutes and was cached for the twenty-four-hour default, so a retry an hour later replayed a200carrying a dead URL. See Idempotency. - Idempotency: replayed responses keep
LocationandETag. Executing anIResultis exactly when those headers get set, and the cached entry carried only status, content type and body - so a created resource replayed as a bare201with 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
tenantclaim, so a root operator acting on two tenants with one key no longer shares a single"root"bucket - and an unresolved, caller-suppliedtenantheader 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:inflightsuffix on the entry key - one request with a key ending in:inflightcould 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 onReservationTtllike 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 toHttpContext.Responseitself 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, soPUT /tickets/1andPUT /tickets/2are 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. The409now carriesRetry-After: 1, andIdempotencyOptionsis validated at startup - a zeroDefaultTtlused 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-registeris anonymous, and there is no user to scope the cache key by, so every unauthenticated caller resolves to the sameanoncaller: 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’s201while 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 - andWithIdempotency()now marks the endpoint withIdempotentEndpointMetadataso an integration test can walk the endpoint map and fail the build if anAllowAnonymous()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 (RedisSET NX, else in-process) under a newIdempotencyOptions.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 receives409 Conflict. Reserve and release both fail open, and the409and the over-long-key400are now RFC 9457ProblemDetailslike every other error on these endpoints. - CORS: the restricted policy now allows
PATCHand every header the two React apps send (fix). WithCorsOptions:AllowAll=false- the shippedappsettings.jsonandappsettings.Production.json- the browser rejected preflights from both apps, becauseAllowedHeaderswas onlycontent-type,authorizationandAllowedMethodslackedPATCH. Dev hid it (appsettings.Development.jsonsetsAllowAll: true). The shipped lists now addtenant(every call),x-fsh-app(login),idempotency-key(chat send), andx-requested-with/x-signalr-user-agent(SignalR negotiate), plusPATCH. Upgrade note: if you overrideCorsOptions:AllowedHeadersorCorsOptions:AllowedMethodsin your own config, add these too, or login, realtime andPATCHcalls 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
IsSensitivebut still stored their raw old and new values, soPasswordHash, security stamps and token values landed inAuditRecordsin clear text. Any property whose name containspassword,secret,token,apikey,connectionstringorsecuritystampis now masked to"****"(anullstaysnull). Rows written before the upgrade are not rewritten - purge or scrub them if that matters for you. A newIAuditExemptmarker inModules.Auditing.Contractsskips an entity in entity-change auditing entirely;[NoAudit]still only affects HTTP activity auditing. See Auditing and #1392. - Forwarded headers:
TrustedProxyOptions:ForwardLimitbelow1is rejected at startup (fix). It was passed straight toForwardedHeadersOptions:0silently left forwarded headers unprocessed, and a negative value made every request fail with500. 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
HtmlEmailhelper inFSH.Framework.Mailing-Encode,Shell,LinkAction,Notice- replaces Identity’s internalEmailBodiesand 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
bodybackground glows forever (fsh-aurora, abackground-positiondrift 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/profilereplaces 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 /profilenow returns the user’sConcurrencyStampas a strongETag, andPUT /profilehonoursIf-Match: a stale token is answered with412 Precondition Failedand nothing is written. The header is optional, so existing callers are unaffected;If-Match: *is accepted, a weak validator never matches (If-Matchmandates strong comparison), and a malformed header is a400rather than a412that 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 ownConcurrencyFailureresult, previously surfacing as a generic500, now maps to the same412, and the sign-in refresh no longer runs when the update failed. No migration -AspNetUsers.ConcurrencyStampwas 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 on412: resending the body built from the values the user saw, against a freshly fetched tag, performs the very overwrite the412rejected. It keeps the edits on screen, adopts the current version, and asks for a deliberate re-save. See Identity. - CORS: the framework now exposes
ETagand allowsIf-Match. AnETag/If-Matchcontract is invisible to a browser on another origin unless the server says so:ETagis not a CORS-safelisted response header, and the kit’s policy never calledWithExposedHeaders, so a front-end readnulland silently stopped sending the precondition. The policy now exposesETagon both the permissive and restricted branches, andif-matchships inCorsOptions.AllowedHeadersinappsettings.jsonandappsettings.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-keyedIOutboxStore/IInboxStoreonce per module DbContext, so .NET DI resolved whichever module registered last for the whole application - and onlyIdentityDbContextmapped the tables. A second module calling it silently repointed every module’s outbox, including Identity’s working one, at a context with noOutboxMessagestable. The tables have moved out ofidentityinto a newframeworkschema owned by a frameworkEventingDbContext; with one owner, both stores collapse to a single unambiguous registration andAddEventingForDbContext<T>is removed. Modules now injectIOutboxWriter(new, inEventing.Abstractions) and publish - no per-module registration of any kind. BecauseEventingDbContextderives fromBaseDbContext, 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 calledAddEventingForDbContext<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 LOCKEDin 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 withoutSKIP LOCKEDfall 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
IEventBuspublishes 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; tuneOutboxDispatchIntervalSeconds(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).
UseHeroPlatformnever calledUseForwardedHeaders, so behind an ingress (cloudflared → Caddy → app)Connection.RemoteIpAddresswas always the proxy’s container IP. Two consequences: the IP-partitioned rate limiters (the anonymousauthpolicy 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 /UserSessionIPs recorded the proxy for every request. The kit now honoursX-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 newTrustedProxyOptionssection (KnownProxies,KnownNetworksCIDRs,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: setTrustedProxyOptionsto your ingress CIDR(s) and hop count - with nothing configured the framework default (loopback only) stands and forwarded headers are ignored.ForwardLimitmust be at least1; a lower value is rejected at startup, since0would 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.UpdateThemecustomise 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 notenant: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’s403, 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
fshCLI anddotnet newtemplate are now on NuGet as stable10.0.0. The two distribution packages that 10.0.0 had been waiting on have shipped:FullStackHero.CLI(install withdotnet tool install -g FullStackHero.CLI- no more--prerelease) andFullStackHero.NET.StarterKit(dotnet new install FullStackHero.NET.StarterKit). Becausefsh newscaffolds from that template, the one-command flow is now end-to-end:dotnet tool install -g FullStackHero.CLI && fsh new MyAppproduces a fully renamed project - unique JWT signing key, generated Docker secrets,npm installrun, initial commit onmain. The Install and CLI pages now lead with the CLI as the recommended path;git cloneand 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.Bodyin the HTML part (MailKit’sBodyBuilder.HtmlBody, SendGrid’shtmlContent), 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 istext/plainbehaviour - 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 handedBodyas both parts, shipping raw markup to text-only clients.MailRequestgains an optionalTextBodyfor thetext/plainalternative:SmtpMailServiceemits both parts as multipart/alternative, andSendGridMailServicemaps them separately ontoplainTextContent/htmlContent. Identity builds its bodies through a newEmailBodieshelper 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 -TextBodyis optional and appended last, so existing callers keep compiling. If you send mail from your own code, put HTML inBodyand the plain wording inTextBody; a bare URL inBodywill 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.Createcould point a webhook at169.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 /localhosttargets at create time, and theWebhooksHTTP 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).
SendGridMailServicediscarded theResponsefromSendEmailAsync, and the client runs withHttpErrorAsException=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/securityand/audits/exceptiondidToListAsyncwith 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 optionalSkip/Takeand cap server-side (page size 1-200, default 50), mirroring the session-list convention. The unifiedGET /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 viaEventingOptions);IOutboxStoregainsGetDeadLetteredAsync/RedriveDeadLettersAsyncto inspect and reset dead-lettered messages; and a new OpenTelemetry meterFSH.Eventingexposesoutbox.deadlettered/outbox.redrivencounters so the condition is alertable. Additive migration (nullableNextRetryAt).
2026-06-24
- Config: the tenant billing grace-period setting was renamed
Billing:GraceWindowDays→Billing:GracePeriodDays(breaking). The option (bound in bothTenantBillingOptionsand Identity’sTenantGraceOptions) is now named consistently with the “grace period” term used everywhere else in the kit; the default of 7 days is unchanged. Deployments that setBilling:GraceWindowDays(envBilling__GraceWindowDays) must rename the key toBilling:GracePeriodDays, otherwise the override is silently ignored and the default applies. No public contract change - theGraceEndsUtctenant-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 aForbiddenExceptionerror band for a user withoutFiles.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 theWebhooks.Viewgate 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.Viewinstead of the realPermissions.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’sseed-demoverb 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
403even 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 (a401while the active token carries theact_subimpersonation claim - a durable signal that works even though production keeps the401body 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
403error 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’sRestore, orFiles.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 of403s. 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 fshtemplate 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 bundleddeploy/**/.terraformprovider binaries,*.tfstatefiles (your infrastructure state - a secret leak), tens of MB ofaudit-dlqruntime dumps, and a stray localrelease-nupkgsoutput dir. Packing is now driven by a git-tracked allowlist - an MSBuild target enumeratesgit ls-filesand 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, theNU5123long-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.OnConnectedAsyncwas 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 resultingOperationCanceledExceptionsurfaced as anError when dispatching 'OnConnectedAsync' on hublog 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.Redisis bumped 2.11.0 → 2.13.17 becauseAspire.Hosting.Redis13.4.0 requires ≥ 2.13.1 (aNU1109downgrade 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-datavolume written by an older Postgres, failing withPostgreSQL 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-runsapply --seed+seed-demoto rebuild and reseed it.Terminal window docker volume rm fsh-starter-postgres-data # use your app's prefix; e.g. acme-store-postgres-datadotnet 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 theChannelMemberrows; 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 got404trying 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
Closedstate was unreachable (no way to get there) and theTickets.Update/Tickets.Deletepermissions 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 newTickets.Closepermission.GET /api/v1/tickets/{id}/commentsnow returns404for 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 newWebhooks.View/Create/Delete/Testpermissions. After upgrading, grant these to the roles that manage webhooks or those users will get403. See Webhooks. - Multitenancy & correctness. The
GET tenant provisioning statusandretry provisioningendpoints now accept and forward aCancellationToken(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 (apageSize=0previously surfaced as a500, now a clean400); and the Billing monthly-invoice job takes its clock fromTimeProviderfor deterministic tests. - Known follow-up. Billing publishes its
InvoiceIssuedevent directly on the in-memory bus rather than through the transactional outbox (Files does the same, with no consumer yet). Closing this needs a smallEventingbuilding-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
fshCLI gained a--versionflag and a corrected (semver-aware) update check; the unimplemented--db sqlserverscaffold option was removed (PostgreSQL is the supported provider); AppHost resource names are namespaced per app; and a batch of scaffold/DX fixes landed (see the dated entries below).
See the dated entries below for the complete list of changes that shipped into 10.0.0.
2026-05-30
- Cross-tenant hardening across billing, subscriptions, and tenant management (security fixes). A deep audit found several handlers that read or mutated data scoped only by a caller-supplied id rather than the caller’s tenant. Because
BillingDbContextis intentionally non-tenant-filtered (so the root operator can see across tenants), each handler must scope explicitly - and several didn’t. A tenant admin (who holds the basicBilling.View/Billing.Managepermissions) could read, issue, pay, or void another tenant’s invoices by id, reassign or cancel another tenant’s subscription via a bodytenantId, read or fabricate another tenant’s usage, or trigger platform-wide invoice generation. The by-id read/PDF paths and every mutation path now gate on the root operator - the operator acts cross-tenant, every other tenant is pinned to its own - andPOST /api/v1/billing/invoices/generateis now operator-only. Separately, a role-permission filter only stripped aPermissions.Root.name prefix that matches no real operator permission, so a non-root tenant admin withRoles.Updatecould grant their own role the operator-onlyTenants.*/Platform.*permissions and escalate to managing every tenant; the filter now keys off the registeredIsRootflag. Existing isolation tests missed all of this because they always authenticate with a matching tenant header - they never exercised one tenant’s token acting on another’s data. New integration tests cover each scenario. (Thetenant-header-vs-JWT-claim path was investigated and is not affected - Finbuckle’s claim strategy binds the resolved tenant to the JWT claim for non-root callers.) - The API now serializes enums as their string names (contract change). Every enum in an API response is emitted as its name (
"Active","Paid","Security") instead of a numeric value, via a globalJsonStringEnumConverter; reading still accepts either form, so request bodies are unaffected.[Flags]enums (AuditTag,BodyCapture) stay numeric. Both bundled React apps already mirror this as string-union types - but if you consume the API from your own client, update any code that switched on numeric enum values. Previously only a couple of modules opted in per-type, so values like a subscription’sstatusserialized as0and surfaced as a stray “0” in the dashboard. - Billing correctness. The monthly usage/overage invoice was silently skipped for any month that already had a subscription invoice (the idempotency check ignored the invoice purpose), so overage went unbilled - it’s now scoped to the usage invoice. A same-plan renewal advanced the tenant’s validity but left the subscription’s end date unchanged, so the dashboard’s subscription term drifted behind the enforced validity; a renewal now extends the subscription term too. Tenant provisioning now checks the admin-user creation result instead of ignoring it (a silent failure previously marked a tenant “provisioned” with no usable admin login). Voiding an invoice is idempotent, invoice-list page size is capped at 100, and the root operator tenant’s validity can no longer be adjusted.
- Front-end polish. The admin console hides plan/invoice/tenant action buttons from operators who lack the matching permission (they previously appeared and failed with
403on submit) and shows a real error state on the invoice page instead of a stuck “Loading…”. The dashboard landing page’s validity now reflects an in-grace or expired tenant (with a persistent expired banner) instead of a healthy day count, surfaces subscription/invoice load errors instead of masking them as an empty state, and paginates the invoice list.
2026-05-28
-
Tenant billing is now complete end-to-end - expiry/renewal emails, PDF invoices, and a tenant-facing billing view. Building on the plan-driven subscription/invoice lifecycle, this round finishes the SaaS billing story. A daily Hangfire scan (
tenant-expiry-scan, 02:00 UTC) classifies every active tenant as nearing expiry, in grace, or expired and emails the tenant admin - deduped so each state notifies once per validity window (and re-arms automatically on renewal). Issuing an invoice now also emails the tenant. Invoices are downloadable as PDF (GET /api/v1/billing/invoices/{id}/pdf, QuestPDF behind a swappableIInvoicePdfRenderer); the download is tenant-scoped, so one endpoint safely serves both the operator console and tenant self-service. The dashboard gains a/subscriptionpage (plan, validity, usage, recent invoices), a global expiry/grace warning banner, and invoice detail with PDF download; the admin console gets a PDF button, client-side plan-form validation, and an Adjust validity operator override (POST /tenants/{id}/adjust-validity) that sets a tenant’s expiry directly with no invoice - for comps and corrections. New config keyBilling:ExpiryNotificationLeadDays(default 7). Note: QuestPDF’s Community license is free for organisations under $1M USD/year revenue; larger commercial users must obtain a license - the dependency is isolated behindIInvoicePdfRendererif you prefer to swap it. -
Background-published lifecycle events no longer crash the webhook fan-out (fix). The generic webhook fan-out handles every integration event and reads a tenant-filtered context that captures the ambient tenant at construction - so events published from a background job (no HTTP request) hit a null tenant and threw. Background publishers (the new expiry scan) now install the tenant context before publishing, so the webhook fan-out and email handlers run correctly. The renewal stacking math also now uses the injected clock (was
DateTime.UtcNow), and aX-Subscription-Graceresponse header reports the days left while a tenant is in its grace window. -
Chat delivers messages live to recipients who weren’t in the conversation when they connected - chat broadcasts each message to the channel’s SignalR group, but a connection only joined the groups for channels it already belonged to at connect time (
AppHub.OnConnectedAsync). So a brand-new DM, or being added to a channel mid-session, never received live messages - the recipient saw nothing until they reloaded the page. The hub now exposes a membership-checkedJoinChannelmethod that the dashboard invokes when a conversation is opened and again on reconnect, so a live socket joins the group on demand. Creating a DM also notifies the other participants (via theiruser:{id}group), so the new conversation appears in their channel rail without a refresh. -
Deactivated tenants are now actually blocked (security fix) - deactivating a tenant only flipped an
IsActiveflag in the tenant store; nothing in the auth or request pipeline enforced it, so a deactivated tenant’s users could still log in and use the API. Tenant resolution now rejects requests for a deactivated tenant with403 Forbidden- covering login, token refresh, and every API/realtime request - via a post-authentication guard. Operators (the root tenant) are exempt so they can still manage and reactivate tenants. Deactivation also now invalidates the tenant’s distributed-cache entry, so the change takes effect on the very next request instead of waiting out the 60-minute cache.
2026-05-27
- Dependencies updated to latest for the v10 release - .NET Aspire 13.3.5 (Hosting packages + AppHost SDK), Finbuckle.MultiTenant 10.1.0, MailKit/MimeKit 4.17.0, AWSSDK.S3 4.0.23.4, Scalar.AspNetCore 2.14.14, and SonarAnalyzer 10.27. Builds clean with warnings-as-errors and the full test suite (unit + Testcontainers integration) stays green.
- Template packaging fixes - scaffolded Dockerfiles and dev-machine packing -
dotnet new fsh/fsh newpacked extensionless files (everyDockerfile) to a doubled nested path, so scaffolded projects got aDockerfiledirectory instead of a file anddeploy/docker(docker compose up) was broken. Also made the IDE-cache excludes (.vs/.idea/.vscode) recursive sodotnet packno longer fails (or bundles IDE junk) when packing the template on a developer machine. Scaffolded output now builds and self-hosts cleanly. - Scaffolded apps log in out of the box, get isolated data volumes, and start on
main- threefsh new/ Aspire DX fixes: the AppHost migrator now runsapply --seed, so the root admin (admin@root.com) is seeded automatically - previously a freshly-run app came up with an empty user table and nobody could log in; each app’s Docker volumes are namespaced by app name (e.g.myapp-postgres-data) instead of sharing a literalpostgres-data, so two FSH-based apps on one machine no longer clobber each other’s database; andfsh newinitializes git onmainrather than following the machine’s git default (oftenmaster). - Demo logins (
acme/globex) work on a fresh Aspire launch - the dashboard’s demo-login panel advertised accounts that were never seeded: the AppHost migrator ran onlyapply --seed(which seeds the root admin), while theacme/globexdemo tenants are created by the dev-onlyseed-demoverb. Aspire now runsseed-demoas a dedicated demo-seeder step after migration - soadmin@acme.com/Password123!works the moment the dashboard loads. Also fixes the migrator crashing at startup in Development (its trimmed service graph tripped the DI container’s build-time validation) and corrects the verb’s environment gate toDOTNET_ENVIRONMENT(the migrator is a generic-host console app, not a web host). - Aspire resource names are namespaced per app - the AppHost’s resource/container names (API, migrator, demo-seeder, admin, dashboard) now derive from the app’s namespace, like the Docker volume names already did. A scaffolded
Acme.Storeshowsacme-store-apietc. instead of the kit’s literalfsh-*, so two FSH-based apps on one machine don’t collide. (This repo resolves tofsh-starter-*; thepostgres/redis/minioinfra and thefsh-dbdatabase keep stable names.) - Stale sessions resolve cleanly instead of erroring - both React apps (admin + dashboard) treated an expired token left in
localStorageas signed-in, firing protected requests that 401’d in a loop (SecurityTokenExpiredException). On boot they now attempt one silent token refresh: success restores the session, failure routes to/login. Long-lived sessions still refresh transparently mid-use. - CI split into path-scoped backend + frontend pipelines - the single
ci.ymlis replaced bybackend.yml(runs only onsrc/**changes) andfrontend.yml(runs only onclients/**), so a client-only change never builds or tests the API, and vice versa. The SDK is pinned to the .NET 10 GA release via a rootglobal.json(no more preview channel). Unit and integration tests each run once, and the coverage gate merges their results instead of re-running the whole solution. The React apps get real CI for the first time - ESLint,tsc/Vite build, and the Playwright E2E suites (admin + dashboard) on Node 22. Branch protection requires the always-resolvingBackend CI/Frontend CIgate jobs. See CI/CD. - Consolidated to a single
mainbranch - the repo now uses one long-lived default branch,main; thedevelopbranch is retired. Branch from and targetmain; stable releases are cut fromv*tags. See Contributing. - Removed the redundant root
docker-compose.yml- local development is covered by .NET Aspire and production bydeploy/docker/, so the overlapping root compose file (added 2026-05-24) was dropped. - Missing required request parameters now return
400, not500- calling a tenant-scoped endpoint without thetenantheader (and any other endpoint missing a required header/route/query parameter, or sent with an unreadable/oversized body) raised an ASP.NETBadHttpRequestExceptionthat the global exception handler rendered as a generic500 Internal Server Error. The handler now honours the framework’s own status code, so these surface as a proper400 Bad Request(or413, etc.) with aProblemDetailsbody. Fixes #1245.
2026-05-24
- Cache/store engine switched from Redis to Valkey 8 - the BSD-licensed, Linux Foundation fork of Redis. It’s a drop-in over the Redis protocol (RESP): the
StackExchange.Redisclient and everyCachingOptions:Redisconfig key are unchanged. Applies to .NET Aspire, both Docker Compose files, and the integration-test container. - RedisInsight cache browser is now auto-wired in Aspire, connected to the Valkey instance so you can inspect cache keys, TTLs, and the SignalR backplane in local dev with no manual configuration.
- Docker Compose hardening - the production
deploy/dockerstack now provisions the MinIO bucket before the API starts (fixes a first-uploadNoSuchBucket); the dev rootdocker-compose.ymlnow runs the DB migrator (apply --seed) so the API never boots against an empty schema.