Skip to content
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-08-07
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. 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](/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-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.
Expand Down
Loading