diff --git a/src/content/docs/changelog/index.mdx b/src/content/docs/changelog/index.mdx index 84da8c8a..d0d4b1d6 100644 --- a/src/content/docs/changelog/index.mdx +++ b/src/content/docs/changelog/index.mdx @@ -1,6 +1,6 @@ --- title: Overview -lastUpdated: 2026-09-30 +lastUpdated: 2026-10-02 description: Release notes and version history for fullstackhero. sidebar: order: 1 @@ -11,6 +11,10 @@ seo: Notable changes to the kit, newest first. +## 2026-10-02 + +- **Identity: expired sessions are now actually cleaned up, in every tenant (fix).** The hourly `SessionCleanupHostedService` is meant to delete sessions that expired more than 30 days ago, but it ran outside any request, so no tenant was resolved and the default-on tenant filter on `UserSessions` threw a `NullReferenceException` on every run (logged as "Error during session cleanup"). Nothing was ever deleted and the table only grew. The service now enumerates the tenant store and runs the delete inside each tenant's context, which also reaches tenants that have their own database; a failure in one tenant is logged with its id and the remaining tenants are still cleaned. Retention (30 days) and interval (hourly) are unchanged, and there is nothing to configure. See [#1406](https://github.com/fullstackhero/dotnet-starter-kit/pull/1406). + ## 2026-09-30 - **Storage: file visibility is now part of the object key, and only `public/*` is anonymously readable (breaking for Docker Compose, Terraform and `Storage:S3:Prefix`).** Public files (avatars, product images) didn't render in the Docker Compose deployment, and "private" relied only on keys being hard to guess: Aspire granted anonymous read on the whole bucket and Terraform's CloudFront could read all of it. Files keys are now `{public|private}/tenants/{tenant}/{owner}/{yyyy}/{MM}/{id}/{name}` (built only by `StorageKeyBuilder`), `S3StorageService.UploadAsync` writes under `public/uploads/...`, and every stack grants anonymous `s3:GetObject` on `public/*` only. Changing a file's visibility moves the object under a per-file row lock (`SELECT ... FOR UPDATE`) and commits the row with a new `FileStorageKeyChangedIntegrationEvent` through the outbox; the old object is deleted once nothing references it, and the outbox retries that delete until it is really gone, so a Public → Private flip can't stay readable under `public/`. Modules that persist a file URL handle the event with `ResolveUrl` (the event carries `NewPublicUrl`); Catalog (product images) and Identity (avatars) do. In BuildingBlocks/Storage, `IStorageService` gains `CopyAsync` (throws on failure; implement it if you ship your own provider), and `QuotaMeteredStorageService` charges a copy and now refunds a delete only when the object is actually gone. The Local provider does not enforce `private/`; it is for dev only. See [Storage](/docs/building-blocks/storage/#key-layout-and-visibility), [Files](/docs/modules/files/#visibility-and-storage-moves) and [#1422](https://github.com/fullstackhero/dotnet-starter-kit/pull/1422).