Skip to content

Commit 29ac1ba

Browse files
hossameldinmiclaude
andcommitted
feat(infra): make rate-limit + JWKS caching Redis-switchable
Cloud runs multiple replicas; per-pod IMemoryCache counters multiply FR-045 OTP limits by replica count, and the old read-modify-write window let concurrent requests slip past the limit even on one host. - IRateLimitStore behind OtpRateLimitPolicies: InMemoryRateLimitStore (Standalone, Interlocked) or RedisRateLimitStore (Cloud) selected by Redis:ConnectionString — same switch pattern as Database:Provider - Redis impl is one atomic Lua INCR+PEXPIRE round-trip; fails open with Error log when Redis is down so a cache outage cannot block all OTP logins (account lockout still guards verification) - Apple JWKS moves to HybridCache (L1 always, Redis L2 in Cloud); raw JSON cached 6h, bad payloads never cached - StatusHealthJob + Supervisor RateLimitMiddleware stay per-node by design; C4 diagrams under docs/architecture/c4/caching-rate-limit/ Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GjzSCHdeKbADHEuunWkdNq
1 parent fed1eaa commit 29ac1ba

64 files changed

Lines changed: 3424 additions & 779 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Directory.Packages.props

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@
4343
<!-- Cron scheduling -->
4444
<PackageVersion Include="NCrontab" Version="3.3.3" />
4545

46+
<!-- Caching / rate limiting -->
47+
<PackageVersion Include="StackExchange.Redis" Version="3.0.17" />
48+
<PackageVersion Include="Microsoft.Extensions.Caching.Hybrid" Version="10.8.0" />
49+
<PackageVersion Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="10.0.10" />
50+
4651
<!-- Auth — Patient App -->
4752
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.5" />
4853
<PackageVersion Include="Microsoft.IdentityModel.Tokens" Version="8.14.0" />
@@ -56,6 +61,7 @@
5661
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
5762
<PackageVersion Include="FluentAssertions" Version="8.3.0" />
5863
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.4.0" />
64+
<PackageVersion Include="Testcontainers.Redis" Version="4.4.0" />
5965
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.5" />
6066
<PackageVersion Include="NSubstitute" Version="5.3.0" />
6167
</ItemGroup>
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# C4 Level 3 — Component: Cache & Rate-Limit Provider Switch
2+
3+
Scope: `Balsm.Infrastructure` cross-cutting plumbing. Introduces `IRateLimitStore` so the
4+
OTP rate limiter (FR-045a/b/c) is atomic and shared across replicas in Cloud (Redis) while
5+
Standalone keeps a zero-dependency in-process store. Apple JWKS caching moves to
6+
`HybridCache` (L1 in-memory always; L2 Redis when configured). Selection key:
7+
`Redis:ConnectionString` — present ⇒ Redis-backed, absent ⇒ in-memory (Standalone never sets it).
8+
9+
Deliberately **not** migrated (per-node state is correct for them):
10+
11+
- `StatusHealthJob` / `StatusController` — each pod reports its own DB liveness (`IMemoryCache`).
12+
- Supervisor `RateLimitMiddleware` — Supervisor is Standalone-only, never multi-node.
13+
14+
```mermaid
15+
C4Component
16+
title Cache & Rate-Limit Provider Switch — Balsm.Infrastructure
17+
18+
Container_Boundary(api, "Balsm.API (composition root)") {
19+
Component(di, "AddSharedInfrastructure", "DependencyInjection.cs", "Registers IRateLimitStore + HybridCache. Switches on Redis:ConnectionString presence")
20+
}
21+
22+
Container_Boundary(authmod, "Balsm.Auth.Infrastructure") {
23+
Component(otpHandler, "RequestOtpHandler", "MediatR handler", "OTP request flow; enforces FR-045 limits before sending email")
24+
}
25+
26+
Container_Boundary(infra, "Balsm.Infrastructure") {
27+
Component(policies, "OtpRateLimitPolicies", "class", "FR-045 tiers: 3/email/10min, 10/IP/60min, 10k/global/60min")
28+
Component(store, "IRateLimitStore", "interface", "TryConsumeAsync(key, limit, window) → RateLimitDecision")
29+
Component(memStore, "InMemoryRateLimitStore", "IMemoryCache + Interlocked", "Standalone default; atomic in-process")
30+
Component(redisStore, "RedisRateLimitStore", "StackExchange.Redis", "Atomic Lua INCR+PEXPIRE, single round-trip; fail-open + Error log on Redis outage")
31+
Component(apple, "AppleOidcValidator", "class", "Caches raw Apple JWKS JSON 6h via HybridCache (stampede-protected)")
32+
Component(hybrid, "HybridCache", "Microsoft.Extensions.Caching.Hybrid", "L1 in-memory always; L2 = IDistributedCache (Redis) when configured")
33+
}
34+
35+
System_Ext(redis, "Redis", "Cloud-only. Shared counters + L2 cache. Not provisioned in Standalone")
36+
System_Ext(appleJwks, "Apple JWKS endpoint", "https://appleid.apple.com/auth/keys")
37+
38+
Rel(otpHandler, policies, "CheckEmailAsync / CheckIpAsync / CheckGlobalAsync")
39+
Rel(policies, store, "TryConsumeAsync")
40+
Rel(di, store, "binds to InMemory (no Redis:ConnectionString) or Redis (present)")
41+
Rel(memStore, store, "implements")
42+
Rel(redisStore, store, "implements")
43+
Rel(redisStore, redis, "EVAL Lua: INCR+PEXPIRE", "TLS")
44+
Rel(apple, hybrid, "GetOrCreateAsync(apple_jwks, 6h)")
45+
Rel(hybrid, redis, "L2 via IDistributedCache (Cloud only)")
46+
Rel(apple, appleJwks, "GET on cache miss", "HTTPS")
47+
```
48+
49+
Layer rules respected: everything lives in `Balsm.Infrastructure` (host plumbing), no module
50+
project references touched; `RequestOtpHandler` (Auth.Infrastructure) already depended on
51+
`OtpRateLimitPolicies` — only the call shape changes (sync → async).
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# C4 Level 4 — Dynamic: OTP Rate-Limit Check (Cloud, Redis-backed)
2+
3+
Flow for `POST /auth/otp/request` (FR-045). Cloud runs N replicas; counters must be shared
4+
and atomic — a plain get/set cache would let concurrent requests on different pods slip past
5+
the limit (read-modify-write race). Redis Lua script performs INCR + PEXPIRE + limit check
6+
in one atomic server-side step.
7+
8+
**Trust boundary & auth:** Redis is inside the Cloud VPC (Railway private network),
9+
reached with connection-string auth over TLS. Rate-limit keys carry no PHI —
10+
`otp:email:{email}` contains the account email (non-PHI cloud data per P001) with a
11+
bounded TTL; nothing else is stored.
12+
13+
**Failure mode — fail-open:** if Redis is unreachable, `RedisRateLimitStore` logs `Error`
14+
and **allows** the request. Rationale: OTP send is defense-in-depth (account lockout still
15+
guards verification); blocking every login on a Redis blip is worse for a healthcare app
16+
than briefly losing spray protection. `AbortOnConnectFail=false` so the multiplexer
17+
reconnects in the background.
18+
19+
```mermaid
20+
C4Dynamic
21+
title OTP request rate-limit path — Cloud mode
22+
23+
Container_Boundary(pod, "Balsm.API pod (replica 1..N)") {
24+
Component(ctrl, "AuthController", "POST /auth/otp/request")
25+
Component(mediatr, "MediatR", "RequestOtpCommand")
26+
Component(handler, "RequestOtpHandler", "Balsm.Auth.Infrastructure")
27+
Component(policies, "OtpRateLimitPolicies", "Balsm.Infrastructure")
28+
Component(store, "RedisRateLimitStore : IRateLimitStore", "Balsm.Infrastructure")
29+
}
30+
System_Ext(redis, "Redis (shared, VPC)", "Counters visible to every replica")
31+
System_Ext(resend, "Resend", "OTP email sub-processor")
32+
33+
Rel(ctrl, mediatr, "1. Send(RequestOtpCommand)")
34+
Rel(mediatr, handler, "2. Handle(cmd, ct)")
35+
Rel(handler, policies, "3. CheckEmailAsync → CheckIpAsync → CheckGlobalAsync")
36+
Rel(policies, store, "4. TryConsumeAsync(otp:email:{email}, 3, 10min)")
37+
Rel(store, redis, "5. EVAL: c=INCR(key); if c==1 PEXPIRE; if c>limit return PTTL else 0")
38+
Rel(store, policies, "6. RateLimitDecision(Allowed, RetryAfterSeconds)")
39+
Rel(handler, resend, "7. Send OTP email (only if all three tiers allow)")
40+
```
41+
42+
Rejected tier ⇒ `OtpRateLimitException(retryAfter, tier)` ⇒ HTTP 429 + `Retry-After`
43+
(unchanged contract — no endpoint, DTO, or Insomnia/OpenAPI change in this refactor).
44+
45+
Standalone mode: identical sequence with `InMemoryRateLimitStore` at step 4–6
46+
(Interlocked increment on an `IMemoryCache` entry — atomic in-process, no network).

src/Balsm.API/appsettings.Production.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
"Database": {
44
"Provider": "PostgreSql"
55
},
6+
"Redis": {
7+
"ConnectionString": ""
8+
},
69
"Serilog": {
710
"MinimumLevel": {
811
"Default": "Information",

src/Balsm.API/packages.lock.json

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,11 @@
372372
"resolved": "10.0.0",
373373
"contentHash": "xZAYhPOU2rUIFpV48xsqhCx9vXs6Y+0jX2LCoSEfDFYMw9jtAOUk3iQsCnDLrFIv9NT3JGMihn7nnuZsPKqJmA=="
374374
},
375+
"RESPite": {
376+
"type": "Transitive",
377+
"resolved": "3.0.17",
378+
"contentHash": "68slEMyRTUNLc75DruEDsEohFmFfNwkHLLtwQ46bobF+8Tl+UXMKM1kM87ihhKy6JdAqy1RrvAfvBWeLmcY9Gg=="
379+
},
375380
"Sentry": {
376381
"type": "Transitive",
377382
"resolved": "4.3.0",
@@ -517,6 +522,11 @@
517522
"System.Composition.Runtime": "9.0.0"
518523
}
519524
},
525+
"System.IO.Hashing": {
526+
"type": "Transitive",
527+
"resolved": "10.0.5",
528+
"contentHash": "8IBJWcCT9+e4Bmevm4T7+fQEiAh133KGiz4oiVTgJckd3Q76OFdR1falgn9lpz7+C4HJvogCDJeAa2QmvbeVtg=="
529+
},
520530
"System.Management": {
521531
"type": "Transitive",
522532
"resolved": "7.0.2",
@@ -891,11 +901,14 @@
891901
"Microsoft.AspNetCore.Authentication.JwtBearer": "[10.0.5, )",
892902
"Microsoft.EntityFrameworkCore": "[10.0.5, )",
893903
"Microsoft.EntityFrameworkCore.Sqlite": "[10.0.5, )",
904+
"Microsoft.Extensions.Caching.Hybrid": "[10.8.0, )",
905+
"Microsoft.Extensions.Caching.StackExchangeRedis": "[10.0.10, )",
894906
"Microsoft.IdentityModel.Tokens": "[8.14.0, )",
895907
"NCrontab": "[3.3.3, )",
896908
"Npgsql.EntityFrameworkCore.PostgreSQL": "[10.0.0, )",
897909
"Sentry.Serilog": "[4.3.0, )",
898910
"Serilog.AspNetCore": "[10.0.0, )",
911+
"StackExchange.Redis": "[3.0.17, )",
899912
"System.IdentityModel.Tokens.Jwt": "[8.14.0, )"
900913
}
901914
},
@@ -1145,6 +1158,21 @@
11451158
"SQLitePCLRaw.core": "2.1.11"
11461159
}
11471160
},
1161+
"Microsoft.Extensions.Caching.Hybrid": {
1162+
"type": "CentralTransitive",
1163+
"requested": "[10.8.0, )",
1164+
"resolved": "10.8.0",
1165+
"contentHash": "RCtCTK3eqKXx8LXqd7B8GjbTkIp4k3EgKeunaCmBJ+WA55Nva83CI2I+wVyp0S9jTG+aT6AYWQbkKOfvFOlmLA=="
1166+
},
1167+
"Microsoft.Extensions.Caching.StackExchangeRedis": {
1168+
"type": "CentralTransitive",
1169+
"requested": "[10.0.10, )",
1170+
"resolved": "10.0.10",
1171+
"contentHash": "E+3pfEY68WQh1ZVFTGDNK0JCoayzL3aIDgVHHM85HIbQO7MiABG14A7+ajHH2P/y3a4W2Rla5p2nzut3+mzoyQ==",
1172+
"dependencies": {
1173+
"StackExchange.Redis": "2.7.27"
1174+
}
1175+
},
11481176
"Microsoft.IdentityModel.Tokens": {
11491177
"type": "CentralTransitive",
11501178
"requested": "[8.14.0, )",
@@ -1171,6 +1199,16 @@
11711199
"Npgsql": "10.0.0"
11721200
}
11731201
},
1202+
"StackExchange.Redis": {
1203+
"type": "CentralTransitive",
1204+
"requested": "[3.0.17, )",
1205+
"resolved": "3.0.17",
1206+
"contentHash": "ItAm9lokZ1mWsQLF3u4Yw4eb/gvkX6Rp9bLAe1KUKgKAjPkALoQwTc6jJLtBZoOv9LT7RwvSIXZZb9FF30QhOg==",
1207+
"dependencies": {
1208+
"RESPite": "3.0.17",
1209+
"System.IO.Hashing": "10.0.5"
1210+
}
1211+
},
11741212
"System.IdentityModel.Tokens.Jwt": {
11751213
"type": "CentralTransitive",
11761214
"requested": "[8.14.0, )",

src/Balsm.Infrastructure.Migrations.Npgsql/packages.lock.json

Lines changed: 69 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -169,22 +169,22 @@
169169
},
170170
"Microsoft.Extensions.Caching.Abstractions": {
171171
"type": "Transitive",
172-
"resolved": "10.0.5",
173-
"contentHash": "k/QDdQ94/0Shi0KfU+e12m73jfQo+3JpErTtgpZfsCIqkvdEEO0XIx6R+iTbN55rNPaNhOqNY4/sB+jZ8XxVPw==",
172+
"resolved": "10.0.10",
173+
"contentHash": "4ZFBNE+jzR+CrWWlhOesnmywCW7pYKT0dxyAQRdL11yJwxe4jvcAu31eorFtEkoFeCDcUTeNssgPv2yaRRptaQ==",
174174
"dependencies": {
175-
"Microsoft.Extensions.Primitives": "10.0.5"
175+
"Microsoft.Extensions.Primitives": "10.0.10"
176176
}
177177
},
178178
"Microsoft.Extensions.Caching.Memory": {
179179
"type": "Transitive",
180-
"resolved": "10.0.5",
181-
"contentHash": "jUEXmkBUPdOS/MP9areK/sbKhdklq9+tEhvwfxGalZVnmyLUO5rrheNNutUBtvbZ7J8ECkG7/r2KXi/IFC06cA==",
180+
"resolved": "10.0.10",
181+
"contentHash": "N1w5H7uK6gCTnCBZAWzE0/EQYSPysij/uYwDqntqBVvBa6bjMmBKitsnEFd6yh/SX3wLm67nO6+OnZ84K+gZWg==",
182182
"dependencies": {
183-
"Microsoft.Extensions.Caching.Abstractions": "10.0.5",
184-
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
185-
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
186-
"Microsoft.Extensions.Options": "10.0.5",
187-
"Microsoft.Extensions.Primitives": "10.0.5"
183+
"Microsoft.Extensions.Caching.Abstractions": "10.0.10",
184+
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10",
185+
"Microsoft.Extensions.Logging.Abstractions": "10.0.10",
186+
"Microsoft.Extensions.Options": "10.0.10",
187+
"Microsoft.Extensions.Primitives": "10.0.10"
188188
}
189189
},
190190
"Microsoft.Extensions.Configuration": {
@@ -267,25 +267,25 @@
267267
},
268268
"Microsoft.Extensions.Logging.Abstractions": {
269269
"type": "Transitive",
270-
"resolved": "10.0.5",
271-
"contentHash": "9HOdqlDtPptVcmKAjsQ/Nr5Rxfq6FMYLdhvZh1lVmeKR738qeYecQD7+ldooXf+u2KzzR1kafSphWngIM3C6ug==",
270+
"resolved": "10.0.10",
271+
"contentHash": "zkFxGYUvdxAvIKTyXHrmW+Sux53D4SezD9dMyZ6hrwwzPQJNuwCRy1f5W7AvYTqacEGhWF2XderRQG1OvbV8og==",
272272
"dependencies": {
273-
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5"
273+
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10"
274274
}
275275
},
276276
"Microsoft.Extensions.Options": {
277277
"type": "Transitive",
278-
"resolved": "10.0.5",
279-
"contentHash": "MDaQMdUplw0AIRhWWmbLA7yQEXaLIHb+9CTroTiNS8OlI0LMXS4LCxtopqauiqGCWlRgJ+xyraVD8t6veRAFbw==",
278+
"resolved": "10.0.10",
279+
"contentHash": "srnhnk7nE8krBiIXp71LvBmKBtraBONWSRzdjJgRv1Ko9Mp8IVNqv4vIS9hGeVteBig8aQkva9ZG+sC+o5sVcA==",
280280
"dependencies": {
281-
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5",
282-
"Microsoft.Extensions.Primitives": "10.0.5"
281+
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10",
282+
"Microsoft.Extensions.Primitives": "10.0.10"
283283
}
284284
},
285285
"Microsoft.Extensions.Primitives": {
286286
"type": "Transitive",
287-
"resolved": "10.0.5",
288-
"contentHash": "/HUHJ0tw/LQvD0DZrz50eQy/3z7PfX7WWEaXnjKTV9/TNdcgFlNTZGo49QhS7PTmhDqMyHRMqAXSBxLh0vso4g=="
287+
"resolved": "10.0.10",
288+
"contentHash": "5wu/GrYVd8mG2DVUw3vFJzF+O336TyTGg/Kmcgw9bfwYhCoFiV5lR5QeEmKecJyrW4W54nMfD3p3589E8a7czQ=="
289289
},
290290
"Microsoft.IdentityModel.Abstractions": {
291291
"type": "Transitive",
@@ -351,6 +351,11 @@
351351
"Microsoft.Extensions.Logging.Abstractions": "10.0.0"
352352
}
353353
},
354+
"RESPite": {
355+
"type": "Transitive",
356+
"resolved": "3.0.17",
357+
"contentHash": "68slEMyRTUNLc75DruEDsEohFmFfNwkHLLtwQ46bobF+8Tl+UXMKM1kM87ihhKy6JdAqy1RrvAfvBWeLmcY9Gg=="
358+
},
354359
"Sentry": {
355360
"type": "Transitive",
356361
"resolved": "4.3.0",
@@ -488,6 +493,11 @@
488493
"System.Composition.Runtime": "9.0.0"
489494
}
490495
},
496+
"System.IO.Hashing": {
497+
"type": "Transitive",
498+
"resolved": "10.0.5",
499+
"contentHash": "8IBJWcCT9+e4Bmevm4T7+fQEiAh133KGiz4oiVTgJckd3Q76OFdR1falgn9lpz7+C4HJvogCDJeAa2QmvbeVtg=="
500+
},
491501
"System.Management": {
492502
"type": "Transitive",
493503
"resolved": "7.0.2",
@@ -504,11 +514,14 @@
504514
"Microsoft.AspNetCore.Authentication.JwtBearer": "[10.0.5, )",
505515
"Microsoft.EntityFrameworkCore": "[10.0.5, )",
506516
"Microsoft.EntityFrameworkCore.Sqlite": "[10.0.5, )",
517+
"Microsoft.Extensions.Caching.Hybrid": "[10.8.0, )",
518+
"Microsoft.Extensions.Caching.StackExchangeRedis": "[10.0.10, )",
507519
"Microsoft.IdentityModel.Tokens": "[8.14.0, )",
508520
"NCrontab": "[3.3.3, )",
509521
"Npgsql.EntityFrameworkCore.PostgreSQL": "[10.0.0, )",
510522
"Sentry.Serilog": "[4.3.0, )",
511523
"Serilog.AspNetCore": "[10.0.0, )",
524+
"StackExchange.Redis": "[3.0.17, )",
512525
"System.IdentityModel.Tokens.Jwt": "[8.14.0, )"
513526
}
514527
},
@@ -565,11 +578,35 @@
565578
"SQLitePCLRaw.core": "2.1.11"
566579
}
567580
},
581+
"Microsoft.Extensions.Caching.Hybrid": {
582+
"type": "CentralTransitive",
583+
"requested": "[10.8.0, )",
584+
"resolved": "10.8.0",
585+
"contentHash": "RCtCTK3eqKXx8LXqd7B8GjbTkIp4k3EgKeunaCmBJ+WA55Nva83CI2I+wVyp0S9jTG+aT6AYWQbkKOfvFOlmLA==",
586+
"dependencies": {
587+
"Microsoft.Extensions.Caching.Abstractions": "10.0.10",
588+
"Microsoft.Extensions.Caching.Memory": "10.0.10",
589+
"Microsoft.Extensions.Logging.Abstractions": "10.0.10",
590+
"Microsoft.Extensions.Options": "10.0.10"
591+
}
592+
},
593+
"Microsoft.Extensions.Caching.StackExchangeRedis": {
594+
"type": "CentralTransitive",
595+
"requested": "[10.0.10, )",
596+
"resolved": "10.0.10",
597+
"contentHash": "E+3pfEY68WQh1ZVFTGDNK0JCoayzL3aIDgVHHM85HIbQO7MiABG14A7+ajHH2P/y3a4W2Rla5p2nzut3+mzoyQ==",
598+
"dependencies": {
599+
"Microsoft.Extensions.Caching.Abstractions": "10.0.10",
600+
"Microsoft.Extensions.Logging.Abstractions": "10.0.10",
601+
"Microsoft.Extensions.Options": "10.0.10",
602+
"StackExchange.Redis": "2.7.27"
603+
}
604+
},
568605
"Microsoft.Extensions.DependencyInjection.Abstractions": {
569606
"type": "CentralTransitive",
570607
"requested": "[10.0.1, )",
571-
"resolved": "10.0.5",
572-
"contentHash": "iVMtq9eRvzyhx8949EGT0OCYJfXi737SbRVzWXE5GrOgGj5AaZ9eUuxA/BSUfmOMALKn/g8KfFaNQw0eiB3lyA=="
608+
"resolved": "10.0.10",
609+
"contentHash": "z/2xXlFw2aLGjHyEm6E0tQ+In6VfzQzTrtArbQ2c0TQE16ZbyDCMGPvaUT9I0s8rgy9sRWlU2P9waW37qV04qA=="
573610
},
574611
"Microsoft.IdentityModel.Tokens": {
575612
"type": "CentralTransitive",
@@ -630,6 +667,17 @@
630667
"Serilog": "4.2.0"
631668
}
632669
},
670+
"StackExchange.Redis": {
671+
"type": "CentralTransitive",
672+
"requested": "[3.0.17, )",
673+
"resolved": "3.0.17",
674+
"contentHash": "ItAm9lokZ1mWsQLF3u4Yw4eb/gvkX6Rp9bLAe1KUKgKAjPkALoQwTc6jJLtBZoOv9LT7RwvSIXZZb9FF30QhOg==",
675+
"dependencies": {
676+
"Microsoft.Extensions.Logging.Abstractions": "10.0.5",
677+
"RESPite": "3.0.17",
678+
"System.IO.Hashing": "10.0.5"
679+
}
680+
},
633681
"System.IdentityModel.Tokens.Jwt": {
634682
"type": "CentralTransitive",
635683
"requested": "[8.14.0, )",

0 commit comments

Comments
 (0)