Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/content/docs/building-blocks/web.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ builder.AddHeroPlatform(o =>
- **`AddHeroOpenTelemetry`** - traces + metrics via OTLP; registers `MediatorTracingBehavior` and wires the caching/Hangfire/module sources and meters.
- **`AddHeroOpenApi`** - OpenAPI documents + Scalar UI at `/scalar`; `OpenApiOptions` (Title, Description, Versions, Contact, License).
- **`AddHeroVersioning`** - `Asp.Versioning` with **URL-segment** versioning only (`api/v{version}/…`), default v1, assumed when unspecified.
- **`AddHeroIdempotency`** - `IdempotencyEndpointFilter` + `IdempotencyOptions` (`HeaderName` default `Idempotency-Key`, `DefaultTtl` 24h, `MaxKeyLength` 128); replay protection via distributed cache.
- **`AddHeroIdempotency`** - `IdempotencyEndpointFilter` + `IdempotencyOptions` (`HeaderName` default `Idempotency-Key`, `DefaultTtl` 24h, `ReservationTtl` 1m, `MaxKeyLength` 128); replay protection via distributed cache.
- **`AddHeroFeatureFlags`** - `Microsoft.FeatureManagement` with the `TenantFeatureFilter` for per-tenant overrides and a `FeatureGateEndpointFilter` for endpoints.
- **`AddHeroSse`** - Server-Sent Events plumbing (`SseConnectionManager`, token service, endpoints via `MapHeroSseEndpoints`).
- **`AddHeroRealtime`** - SignalR (`AppHub` at `/api/v1/realtime/hub` + presence endpoint) with a Redis backplane when `CachingOptions:Redis` is set.
Expand Down
12 changes: 11 additions & 1 deletion src/content/docs/changelog/index.mdx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: Overview
lastUpdated: 2026-07-13
lastUpdated: 2026-08-12
description: Release notes and version history for fullstackhero.
sidebar:
order: 1
Expand All @@ -11,6 +11,16 @@ seo:

Notable changes to the kit, newest first.

## 2026-08-12

- **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. See [Idempotency](/docs/cross-cutting-concerns/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.

## 2026-07-13

- **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).
Expand Down
Loading