Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
0359dea
fix(web): correct idempotent replay payload and serialize concurrent …
marcelo-maciel Jul 11, 2026
e669afc
fix(web): make idempotency replay actually engage (symmetric cache st…
marcelo-maciel Jul 11, 2026
8a8761a
fix(web): short-TTL, fail-open idempotency reservation
marcelo-maciel Jul 13, 2026
f7c8c32
fix(web): make the stored idempotent response outlive the request
marcelo-maciel Aug 12, 2026
52365f3
fix(web): scope the idempotency entry to the operation, not the tenan…
marcelo-maciel Aug 12, 2026
53ef779
fix(web): close the idempotency reservation's races
marcelo-maciel Aug 12, 2026
b18e541
fix(web): keep an idempotent handler alive past a client disconnect
marcelo-maciel Aug 12, 2026
08fcade
test(web): cover the idempotency branches no test could fail on
marcelo-maciel Aug 12, 2026
f3d66c5
test(identity): pin that the caller id claim survives bearer inbound …
marcelo-maciel Aug 12, 2026
5b33004
docs(agents): idempotency rule covers the abort-token, probe and key …
marcelo-maciel Aug 12, 2026
e6b0bb7
test(web): assert idempotency options through IStartupValidator, not …
marcelo-maciel Aug 12, 2026
75eb3b9
test(web): pin each idempotency options clause to its own failure mes…
marcelo-maciel Aug 12, 2026
430122a
fix(identity): drop idempotency from self-registration, and gate anon…
marcelo-maciel Aug 12, 2026
b277001
test(multitenancy): carry the response body into the theme status ass…
marcelo-maciel Aug 13, 2026
c204603
build(deps): pin SSH.NET to the patched 2026.0.0
marcelo-maciel Aug 13, 2026
7c2dcba
fix(web): keep request-derived text out of the idempotency warning
marcelo-maciel Aug 13, 2026
a5ad8ba
fix(web): detach the bound CancellationToken argument, not only Reque…
marcelo-maciel Sep 16, 2026
7ade0a2
build(deps): bump Testcontainers to 4.14.0 and SourceLink past their …
marcelo-maciel Sep 14, 2026
c37820b
fix(infra): pull MinIO from quay.io on a pinned tag, not Docker Hub
marcelo-maciel Sep 14, 2026
7d26217
fix(infra): pull minio/mc from quay.io too, not just minio/minio
marcelo-maciel Sep 18, 2026
cbfc5d6
build(deps): drop the dead SSH.NET pin
marcelo-maciel Sep 18, 2026
fde7d0a
test(idempotency): exercise the Redis refusal, not just the in-proces…
marcelo-maciel Sep 18, 2026
2289315
fix(idempotency): let an endpoint cap its own replay window
marcelo-maciel Sep 18, 2026
5f74d75
test(idempotency): make the client-abort test actually abort
marcelo-maciel Sep 18, 2026
2b8783c
Merge remote-tracking branch 'origin/main' into pr/1378
iammukeshm Sep 25, 2026
121a450
docs(agents): condense the idempotency rule into scannable bullets
iammukeshm Sep 25, 2026
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
8 changes: 7 additions & 1 deletion .agents/rules/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,13 @@ Chained partitioned fixed-window limiter: **tenant → user → IP** (defaults 1

## Idempotency (`Web/Idempotency/`)

Opt-in per endpoint with **`.WithIdempotency()`**. Reads the `Idempotency-Key` header (max 128 chars, 24h TTL); replays return the cached response with `Idempotency-Replayed: true`. Cache key is tenant-scoped (`CacheKeys.IdempotencyEntry`). Put it on POSTs that must be replay-safe (e.g. CreateTenant).
Opt-in per endpoint with **`.WithIdempotency()`** on **authenticated** POST/PUTs that must be replay-safe (e.g. CreateTenant). Reads `Idempotency-Key` (max 128 chars, `DefaultTtl` 24h); a replay returns the cached status + body + `Location`/`ETag` and `Idempotency-Replayed: true`.

- **Key scope:** resolved tenant (never the raw `tenant` header) + operation (method + route pattern) + route values + caller (`GetUserId()`, else `"anon"`), via `CacheKeys.IdempotencyEntry`.
- **Only 2xx is stored**, before the body is sent, on `CancellationToken.None`. Probe and write must share one `IDistributedCache` + key + serializer, or replay silently never engages.
- **Concurrent duplicates:** in-flight reservation under a `lock:` prefix (Redis `SET NX`, else in-process, `ReservationTtl` default 1m); cache re-probed after the lock; duplicate in flight → **409** + `Retry-After: 1`. Probe, reserve and release all fail open.
- **`RequestAborted` is detached** while the handler runs, so a client disconnect can't cancel a committed side effect. Keep these handlers short; **never** on streaming or large-file endpoints (the response is buffered).
- **Never on `AllowAnonymous()`** endpoints — all anonymous callers share `"anon"`. `IdempotencyWiringTests` fails the build if one appears.

## Quota enforcement (`Quota/`)

Expand Down
6 changes: 5 additions & 1 deletion src/BuildingBlocks/Caching/CacheKeys.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@ public static class Tags
/// <summary>Tag applied to every tenant theme entry.</summary>
public const string Themes = "themes";

/// <summary>Tag applied to every idempotency replay entry.</summary>
/// <summary>
/// Reserved for idempotency replay entries — not applied to them today. Those entries live in
/// <c>IDistributedCache</c>, which carries no tags, so a tag purge does not reach them; they
/// expire on their own TTL instead.
/// </summary>
public const string Idempotency = "idempotency";

/// <summary>Per-tenant tag — invalidates all entries scoped to a tenant.</summary>
Expand Down
19 changes: 12 additions & 7 deletions src/BuildingBlocks/Web/Idempotency/CachedIdempotentResponse.cs
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
using System.ComponentModel;

namespace FSH.Framework.Web.Idempotency;

/// <summary>
/// A cached HTTP response for idempotent replay.
/// </summary>
/// <remarks>
/// Marked <see cref="ImmutableObjectAttribute"/> + <c>sealed</c> so HybridCache can reuse the
/// in-process instance across requests without re-deserializing on every L1 hit.
/// </remarks>
[ImmutableObject(true)]
public sealed record CachedIdempotentResponse
{
public int StatusCode { get; init; }

public string? ContentType { get; init; }

public byte[] Body { get; init; } = [];

/// <summary>
/// Response headers replayed alongside the body. Only headers that carry meaning for the caller
/// are captured (see the filter's allow-list) — the host sets the transport ones itself, and
/// replaying a stale <c>Content-Length</c> or <c>Transfer-Encoding</c> would corrupt the response.
/// Defaults to empty so entries written before headers were captured still deserialize.
/// Iterate it, do not look a header up by name: deserialization replaces this instance with a
/// plain case-SENSITIVE dictionary, so the initializer's comparer only holds on the write path.
/// </summary>
public IReadOnlyDictionary<string, string> Headers { get; init; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
14 changes: 13 additions & 1 deletion src/BuildingBlocks/Web/Idempotency/Extensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,20 @@ public static IServiceCollection AddHeroIdempotency(this IServiceCollection serv
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);

// Validated at startup, like every other options block here. A misconfigured TTL fails
// SILENTLY otherwise: a zero or negative DefaultTtl throws inside the best-effort cache
// write, which logs a warning and moves on, so no response is ever stored and replay never
// engages — the exact silent failure this filter exists to have stopped having. A
// ReservationTtl of zero expires the in-flight lock the moment it is taken, so concurrent
// duplicates both run the handler.
services.AddOptions<IdempotencyOptions>()
.BindConfiguration(nameof(IdempotencyOptions));
.BindConfiguration(nameof(IdempotencyOptions))
.Validate(o => !string.IsNullOrWhiteSpace(o.HeaderName), "IdempotencyOptions.HeaderName is required.")
.Validate(o => o.DefaultTtl > TimeSpan.Zero, "IdempotencyOptions.DefaultTtl must be greater than zero.")
.Validate(o => o.ReservationTtl > TimeSpan.Zero, "IdempotencyOptions.ReservationTtl must be greater than zero.")
.Validate(o => o.ReservationTtl <= o.DefaultTtl, "IdempotencyOptions.ReservationTtl must not exceed DefaultTtl — the reservation only has to outlast the handler.")
.Validate(o => o.MaxKeyLength > 0, "IdempotencyOptions.MaxKeyLength must be greater than zero.")
.ValidateOnStart();

return services;
}
Expand Down
Loading
Loading