From c30557ca30c1a0d44d660ff5e93ef68fa36b33ab Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 20 May 2026 15:49:46 +0200 Subject: [PATCH] docs: sync site against recent framework changes Catches the site up with PRs since the last sync (#119): - Adds guides for Notifications (#194), Rate Limiting (#206), Broadcasting (#193), and Identity & Sessions (#187/#188/#185/#198). - Documents signed URLs (#195) in the File Storage guide. - Replaces the "not a durable queue" warning in events.md with the actual Wolverine inbox/outbox story (#192). - Refreshes Button variants in the components reference (#157). - Rewrites docs/missing-cross-cutting-modules.md to mark the now- covered modules done and trim the open backlog. - Sidebar updated; pre-existing `` parse error in soft-delete.md fixed so the build is green. --- docs/missing-cross-cutting-modules.md | 112 ++++------------------- docs/site/.vitepress/config.ts | 4 + docs/site/frontend/components.md | 27 +++++- docs/{ => site/guide}/broadcasting.md | 4 + docs/site/guide/events.md | 7 +- docs/site/guide/file-storage.md | 35 ++++++++ docs/site/guide/identity.md | 97 ++++++++++++++++++++ docs/site/guide/notifications.md | 122 ++++++++++++++++++++++++++ docs/site/guide/rate-limiting.md | 80 +++++++++++++++++ docs/site/guide/soft-delete.md | 2 +- 10 files changed, 392 insertions(+), 98 deletions(-) rename docs/{ => site/guide}/broadcasting.md (99%) create mode 100644 docs/site/guide/identity.md create mode 100644 docs/site/guide/notifications.md create mode 100644 docs/site/guide/rate-limiting.md diff --git a/docs/missing-cross-cutting-modules.md b/docs/missing-cross-cutting-modules.md index e42313f4..7a6babd5 100644 --- a/docs/missing-cross-cutting-modules.md +++ b/docs/missing-cross-cutting-modules.md @@ -1,128 +1,54 @@ # Missing Cross-Cutting Modules -Analysis of cross-cutting concerns not yet covered by the framework. +Snapshot of cross-cutting concerns and where each one stands. Everything under "Already Covered" ships in the box; the remaining sections are an open backlog. ## Already Covered - Exception handling (`GlobalExceptionHandler`) - Authorization & permissions (`PermissionRegistry`, `IPermissionContracts`) -- Event bus (Wolverine `IMessageBus`) +- Event bus with durable inbox/outbox (Wolverine `IMessageBus`, EF Core persistence) +- Real-time push (`/hub/broadcast` SignalR hub + `@simplemodule/echo` client) — see [Broadcasting](./site/guide/broadcasting.md) - Validation (FluentValidation + `ValidationResultExtensions`) - Settings (`ISettingsContracts`, `SettingDefinitionRegistry`) - Menu system (`IMenuRegistry`) - Health checks (`DatabaseHealthCheck`, `/health/live`, `/health/ready`) - Structured logging + OpenTelemetry -- Database multi-provider & schema isolation -- Authentication (OpenIddict OIDC + ASP.NET Identity) +- Database multi-provider & schema isolation, soft-delete recovery +- Authentication (OpenIddict OIDC + ASP.NET Identity, active sessions, sign-out everywhere, phone confirmation, self-service unlock) +- Audit log (automatic EF Core change tracking via `AuditLogs` module) +- Background jobs (`BackgroundJobs` module) +- Notifications (`INotifier`, mail / SMS / database channels — see [Notifications](./site/guide/notifications.md)) +- File storage (local / S3 / Azure providers + signed-URL generator) +- Localization (`Localization` module, culture middleware, translations admin UI) +- Feature flags (`FeatureFlags` module) +- Rate limiting (DB-defined policies, per-endpoint `.RateLimit(...)`, admin UI — see [Rate Limiting](./site/guide/rate-limiting.md)) - Test infrastructure (`SimpleModuleWebApplicationFactory`) - Compile-time module discovery (source generator) --- -## High Value - -### AuditLog - -Current audit logging in Admin is manual (`AuditService.LogAsync()`). An AuditLog module would provide automatic change tracking via an EF Core `SaveChanges` interceptor across all module DbContexts. Every module benefits immediately with zero code changes. - -- `IAuditContracts` for querying audit history -- EF Core interceptor that captures entity changes (create, update, delete) -- Admin UI for browsing audit trail with filtering -- Per-module opt-in/out via configuration - -### BackgroundJobs - -No scheduled or deferred work abstraction exists. Modules need cleanup tasks, async processing, and retries. - -- `IJobScheduler` contract for scheduling one-off and recurring jobs -- Wraps Hangfire or a lightweight hosted-service scheduler -- Dashboard UI for job monitoring -- Module hook: `ConfigureJobs()` on `IModule` - -### Notifications - -`ConsoleEmailSender` is the only notification path. A Notifications module would give all modules a unified way to notify users. - -- Channels: email, in-app, push -- `INotificationService` contract -- Template system for notification content -- User notification preferences (integrates with Settings) -- Natural event bus consumer (e.g., `OrderCreatedEvent` → email) - -### FileStorage - -No file/blob abstraction. Products need images, PageBuilder needs media, Users need avatars. - -- `IFileStorage` contract with upload/download/delete -- Providers: local filesystem, S3, Azure Blob Storage -- Thumbnail generation -- Admin UI for media library -- Per-module storage isolation - ---- - -## Medium Value - -### Localization - -Settings has `app.language` but nothing consumes it. A Localization module would enable multi-language support. - -- Resource management with key-value translations -- Middleware for culture-aware responses -- Admin UI for managing translations -- Module hook: `ConfigureResources()` on `IModule` +## Open Backlog ### Caching -No caching abstraction exists. Each module must implement its own caching. +No first-class caching abstraction yet. Modules that need caching wire `IMemoryCache` themselves. - `IModuleCache` contract with get/set/invalidate - Backends: in-memory, Redis - Cache invalidation via event bus integration - Per-module cache configuration -### FeatureFlags - -No way to toggle features per module, user, or role. - -- `IFeatureFlagService` contract -- Scopes: global, per-module, per-role, per-user -- Admin UI for flag management -- Module hook: `ConfigureFeatureFlags()` on `IModule` - ---- - -## Nice to Have - -### RateLimiting - -No throttling or DoS protection. - -- Per-module rate limit policies -- Module hook: `ConfigureRateLimits()` on `IModule` -- IP-based and user-based throttling - ### Webhooks -External event delivery for third-party integrations. +External event delivery for third-party integrations. Complements the internal event bus by fanning selected events out over HTTP. -- Complements the internal event bus - Webhook registration and management UI -- Retry with exponential backoff -- Signature verification for security - -### RealTime - -No SignalR/WebSocket support for live updates. - -- `IRealtimeHub` contract for broadcasting -- Live notifications, dashboard updates -- Per-module channel isolation +- Retry with exponential backoff (already supportable via Wolverine + BackgroundJobs) +- HMAC signature verification for security --- ## Recommended Priority -1. **AuditLog** — highest leverage, every module benefits automatically -2. **BackgroundJobs** — unlocks async workflows across all modules -3. **FileStorage** — unblocks richer content for Products, PageBuilder, Users +1. **Caching** — high leverage, several modules (Settings, Permissions, Menus) would benefit immediately. +2. **Webhooks** — unlocks external integrations once a single tenant goes multi-system. diff --git a/docs/site/.vitepress/config.ts b/docs/site/.vitepress/config.ts index e9f98ce4..097e29a8 100644 --- a/docs/site/.vitepress/config.ts +++ b/docs/site/.vitepress/config.ts @@ -50,6 +50,7 @@ export default defineConfig({ text: 'Communication', items: [ { text: 'Events', link: '/guide/events' }, + { text: 'Broadcasting', link: '/guide/broadcasting' }, { text: 'Permissions', link: '/guide/permissions' }, { text: 'Menus', link: '/guide/menus' }, { text: 'Settings', link: '/guide/settings' }, @@ -61,6 +62,9 @@ export default defineConfig({ items: [ { text: 'Background Jobs', link: '/guide/background-jobs' }, { text: 'File Storage', link: '/guide/file-storage' }, + { text: 'Notifications', link: '/guide/notifications' }, + { text: 'Rate Limiting', link: '/guide/rate-limiting' }, + { text: 'Identity & Sessions', link: '/guide/identity' }, { text: 'Localization', link: '/guide/localization' }, { text: 'Error Pages', link: '/guide/error-pages' }, ], diff --git a/docs/site/frontend/components.md b/docs/site/frontend/components.md index 7e44861c..bf2c765b 100644 --- a/docs/site/frontend/components.md +++ b/docs/site/frontend/components.md @@ -80,7 +80,7 @@ This avoids class conflicts (e.g., `p-4` and `p-2` don't both end up in the DOM | Component | Description | |---|---| -| `Button` | Button with variants (default, destructive, outline, secondary, ghost, link) | +| `Button` | Button with variants (`primary`, `secondary`, `ghost`, `danger`, `outline`) and sizes (`sm`, `default`, `lg`) | | `Input` | Text input with variants | | `Textarea` | Multi-line text input | | `Checkbox` | Checkbox input | @@ -152,6 +152,31 @@ function CreateForm() { } ``` +### Button Variants + +```tsx +import { Button } from '@simplemodule/ui'; + + {/* variant="primary" by default */} + + + + + + + +``` + +`asChild` forwards the variant styles to a child element (useful with ``): + +```tsx +import { Link } from '@inertiajs/react'; + + +``` + ### Data Grid Page ```tsx diff --git a/docs/broadcasting.md b/docs/site/guide/broadcasting.md similarity index 99% rename from docs/broadcasting.md rename to docs/site/guide/broadcasting.md index b6a7d59a..230b9327 100644 --- a/docs/broadcasting.md +++ b/docs/site/guide/broadcasting.md @@ -1,3 +1,7 @@ +--- +outline: deep +--- + # Broadcasting Real-time push from server to browser, layered on SignalR. diff --git a/docs/site/guide/events.md b/docs/site/guide/events.md index 275eafd7..663fe7f3 100644 --- a/docs/site/guide/events.md +++ b/docs/site/guide/events.md @@ -89,13 +89,14 @@ The `DomainEventInterceptor` (registered by the hosting layer) picks up queued e Wolverine routes `PublishAsync` to **every matching handler** in the process: -- **In-process only.** The framework configures Wolverine with no external transports and no durable outbox — events are not persisted and are not retried across process restarts. +- **Durable inbox/outbox.** The framework persists envelopes to the configured database (PostgreSQL, SQL Server, or SQLite) via Wolverine's EF Core integration — `PersistMessagesWithPostgresql/SqlServer/Sqlite` plus `UseDurableInboxOnAllListeners()`. Events queued by `SaveChangesAndFlushMessagesAsync` commit atomically with the EF write, so handlers never run for a transaction that rolled back. +- **Restart-safe.** Envelopes in flight when the process dies are picked up by the next instance and dispatched. - **Handler isolation.** Each handler runs in its own dispatch. A failing handler does not stop dispatch to the others. - **Exceptions surface.** By default, handler exceptions are logged and rethrown once all handlers have been attempted. If you need finer control, configure Wolverine policies in `builder.Host.UseWolverine(opts => ...)`. - **Audit capture.** The AuditLogs module wraps `IMessageBus` with `AuditingMessageBus`, which records an audit entry for every published `IEvent`. Audit failures are swallowed and logged — they never break the primary operation. -::: warning Not a durable queue -Wolverine is running in-memory here. For work that must survive a restart, use the [Background Jobs](/guide/background-jobs) module instead of relying on events. +::: tip Long-running work +Handlers still run inline with the publishing scope. For anything expensive (PDF rendering, external HTTP, batch writes) hand off to the [Background Jobs](/guide/background-jobs) module from inside the handler rather than blocking the dispatch. ::: ## Handler Best Practices diff --git a/docs/site/guide/file-storage.md b/docs/site/guide/file-storage.md index 81b8b4d4..89af5d57 100644 --- a/docs/site/guide/file-storage.md +++ b/docs/site/guide/file-storage.md @@ -156,6 +156,41 @@ All paths are normalized to forward slashes internally. The `StoragePathHelper` - Leading/trailing slashes are normalized - File names and folder names are extracted consistently across providers +## Signed URLs + +Use `ISignedUrlGenerator` when you need to hand a recipient a time-bound, anonymous link to a protected endpoint — typical for download links pasted into emails, share-by-link flows, or browser redirects to a file the user is allowed to fetch right now but not in general. + +```csharp +public sealed class ShareReportEndpoint(ISignedUrlGenerator urls) : IEndpoint +{ + public void Map(IEndpointRouteBuilder app) => + app.MapPost("/api/reports/{id}/share", (ReportId id) => + { + var url = urls.Sign( + path: $"/api/reports/{id}/export", + expiresAt: DateTimeOffset.UtcNow.AddMinutes(15), + purpose: "report-export"); + + return Results.Ok(new { url }); + }); +} +``` + +On the endpoint that consumes the link, gate it with `RequireSignedUrl(purpose)`: + +```csharp +public sealed class ExportByLinkEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder app) => + app.MapGet("/api/reports/{id}/export", Handle) + .RequireSignedUrl(purpose: "report-export"); +} +``` + +`RequireSignedUrl` calls `AllowAnonymous()` and replaces the auth check with a signature check — a missing, tampered, expired, or wrong-purpose link returns `403`. The validated `SignedUrlClaims` (path, purpose, expiry) are stashed on `HttpContext.Items` and retrievable via `httpContext.GetSignedUrlClaims()` if the handler needs them. + +The `purpose` string is a free-form scope. Use distinct purposes per flow (`report-export`, `email-confirm`, `account-unlock`) so a leaked link from one flow cannot be replayed against another endpoint. + ## Next Steps - [Configuration](/reference/configuration) -- all storage configuration options diff --git a/docs/site/guide/identity.md b/docs/site/guide/identity.md new file mode 100644 index 00000000..c8640f83 --- /dev/null +++ b/docs/site/guide/identity.md @@ -0,0 +1,97 @@ +--- +outline: deep +--- + +# Identity & Sessions + +The Users module owns the local identity store; the OpenIddict module owns issued tokens. This page covers the user-facing flows that span them: account lockout recovery, phone verification, active-session management, and global sign-out. + +## Account lockout and self-service unlock + +When ASP.NET Identity locks an account after repeated failed logins the user is redirected to `/Identity/Account/Lockout`. From there `Send unlock email` posts to `/Identity/Account/SendUnlockEmail`, which: + +1. Resolves the user by email (silently no-ops on miss to avoid enumeration). +2. Generates a single-use token bound to the user and the `AccountUnlock` purpose. +3. Calls `IAccountUnlockEmailSender.SendUnlockLinkAsync(email, unlockLink)`. + +Clicking the link lands on `/Identity/Account/UnlockAccount`, which validates the token, calls `userManager.SetLockoutEndDateAsync(...)` to clear the lockout, and signs the user out so they re-enter credentials. + +`IAccountUnlockEmailSender` defaults to `ConsoleAccountUnlockEmailSender` (logs the link). Replace it with a production implementation that hands off to your transactional mail provider: + +```csharp +public sealed class MailgunAccountUnlockEmailSender(IMailgunClient client) : IAccountUnlockEmailSender +{ + public Task SendUnlockLinkAsync(string email, string unlockLink) => + client.SendAsync(to: email, subject: "Unlock your account", html: Templates.Unlock(unlockLink)); +} +``` + +Register the replacement in `Program.cs` after `AddSimpleModuleInfrastructure()`: + +```csharp +builder.Services.AddScoped(); +``` + +## Phone number confirmation + +The account manage page collects an unconfirmed phone number and offers `Send code`. That action posts to `/Identity/Account/Manage/SendPhoneVerificationCode`, which uses `userManager.GenerateChangePhoneNumberTokenAsync(...)` and dispatches via `ISmsSender`: + +```csharp +public interface ISmsSender +{ + Task SendVerificationCodeAsync( + ApplicationUser user, + string phoneNumber, + string code, + CancellationToken cancellationToken = default); +} +``` + +Provide your own implementation (Twilio, Vonage, AWS SNS) and register it the same way as the unlock sender. The default `ConsoleSmsSender` writes the code to logs for local development. + +`/Identity/Account/Manage/ConfirmPhoneNumber` verifies the code with `userManager.ChangePhoneNumberAsync(...)`, which sets both the number and `PhoneNumberConfirmed = true`. `/Identity/Account/Manage/RemovePhoneNumber` clears both fields. + +## Active sessions + +Every refresh token issued by OpenIddict represents a live session. The manage page at `/Identity/Account/Manage` lists them so a user can audit and revoke individual logins without changing their password. + +Sessions are exposed via `IOpenIddictSessionContracts`. The session-grouped overload collapses access + refresh tokens that share an `AuthorizationId` into a single row, so a user can't accidentally revoke half of their own login: + +```csharp +public interface IOpenIddictSessionContracts +{ + Task> GetActiveSessionsForUserAsync( + string userId, + string? currentTokenId, + CancellationToken cancellationToken = default); + + Task TryRevokeSessionForUserAsync( + string tokenId, + string userId, + string? currentTokenId, + CancellationToken cancellationToken = default); + + Task RevokeAllSessionsForUserAsync(string userId, CancellationToken cancellationToken = default); + + Task RevokeOtherSessionsForUserAsync( + string userId, + string? currentTokenId, + CancellationToken cancellationToken = default); +} +``` + +`UserSessionDto` carries `TokenId`, `Type`, `ApplicationName`, `CreationDate`, `ExpirationDate`, and an `IsCurrent` flag set when the row belongs to the request's own session. + +`TryRevokeSessionForUserAsync` returns `RevokeSessionResult.NotFound` (404) for unknown or cross-user tokens — the endpoint deliberately does not distinguish "doesn't exist" from "belongs to someone else" — and `BlockedCurrent` (400) when the caller tries to revoke their own session, which would log them out mid-request. + +## Sign out everywhere + +`/Identity/Account/Manage/SignOutEverywhere` calls `RevokeOtherSessionsForUserAsync` (passing the current token id) and then bumps the user's security stamp via `userManager.UpdateSecurityStampAsync(...)`. The stamp change invalidates every cookie auth ticket issued before the bump, so even browser sessions held outside the OAuth flow are forced through re-authentication. + +For credential-compromise flows, combine `RevokeAllSessionsForUserAsync` with `UpdateSecurityStampAsync` so even cookie-based sessions issued before the stamp bump are invalidated. + +## Next Steps + +- [Permissions](/guide/permissions) — claims-based authorization layered on top of identity. +- [Notifications](/guide/notifications) — channel the unlock and verification messages through a unified pipeline. +- [Settings](/guide/settings) — toggle lockout policy thresholds without redeploying. diff --git a/docs/site/guide/notifications.md b/docs/site/guide/notifications.md new file mode 100644 index 00000000..cf4cc3cc --- /dev/null +++ b/docs/site/guide/notifications.md @@ -0,0 +1,122 @@ +--- +outline: deep +--- + +# Notifications + +The Notifications module gives every module one way to reach a user — over mail, SMS, or the in-app inbox — without coupling to the delivery channel. You write a notification type once; the channels decide what to send. + +## Defining a notification + +Implement `INotification` and declare which channels should attempt delivery. Return a payload only for the channels you implement; channels you skip are quietly ignored. + +```csharp +using SimpleModule.Notifications.Contracts; +using static SimpleModule.Notifications.Contracts.NotificationsConstants.Channels; + +public sealed class OrderShipped(string trackingNumber) : INotification +{ + public string NotificationType => "orders.shipped"; + + public string[] Via(NotificationRecipient recipient) => [Mail, Database]; + + public MailMessage ToMail(NotificationRecipient recipient) => new( + subject: "Your order is on its way", + body: $"Track it: {trackingNumber}"); + + public DatabaseNotificationPayload ToDatabase(NotificationRecipient recipient) => new( + title: "Order shipped", + body: trackingNumber, + data: new { trackingNumber }); +} +``` + +Channel constants live on `NotificationsConstants.Channels`: `Mail`, `Database`, `Sms`. + +## Sending + +Inject `INotifier` and call `SendAsync` for normal traffic, `SendNowAsync` for tests or flows where the caller wants delivery errors to surface immediately: + +```csharp +public sealed class OrderService(INotifier notifier) +{ + public async Task ShipAsync(Order order, CancellationToken ct) + { + // ... + var recipient = new NotificationRecipient(order.UserId, order.Email); + await notifier.SendAsync(recipient, new OrderShipped(order.TrackingNumber), ct); + } +} +``` + +`SendAsync` queues delivery via the BackgroundJobs module so the caller does not block on slow channels (SMTP, SMS gateway). `SendNowAsync` dispatches synchronously on the calling thread. + +## Channels + +Each channel is enabled per environment via Settings: + +| Setting key | Default | Description | +|---|---|---| +| `notifications.channel.mail.enabled` | `true` | Toggles `MailChannel`, which calls into the Email module. | +| `notifications.channel.database.enabled` | `true` | Toggles persistence to the inbox table read by the `/notifications/` UI. | +| `notifications.channel.sms.enabled` | `false` | Toggles `SmsChannel`, which uses the registered `ISmsSender`. | +| `notifications.defaultPageSize` | `20` | Default page size for inbox queries. | + +A channel only attempts delivery if (a) the notification's `Via(...)` includes its name and (b) the channel is enabled. + +## In-app inbox + +When the `Database` channel persists a notification it shows up in the recipient's inbox at `/notifications/`. Modules that need to read or mutate the inbox depend on the contract: + +```csharp +public interface INotificationsContracts +{ + Task> ListAsync(UserId userId, QueryNotificationsRequest request); + Task GetUnreadCountAsync(UserId userId, CancellationToken cancellationToken = default); + Task GetByIdAsync(NotificationId id, UserId userId); + Task MarkReadAsync(NotificationId id, UserId userId); + Task MarkAllReadAsync(UserId userId); +} +``` + +Inbox API endpoints (`/api/notifications/`): + +| Method | Route | Description | +|---|---|---| +| `GET` | `/` | List notifications for the current user (paged, optional `?unread=true`). | +| `GET` | `/unread-count` | Unread count — useful for badges. | +| `POST` | `/{id}/read` | Mark one notification as read. | +| `POST` | `/read-all` | Mark all unread notifications as read. | + +## Domain events + +Every send attempt emits exactly one of: + +- `NotificationSentEvent(UserId, NotificationType, Channel)` +- `NotificationFailedEvent(UserId, NotificationType, Channel, Error)` + +These flow through the same [event bus](/guide/events) as the rest of the framework, so other modules can react (audit log, retry queue, alerting) without depending on the Notifications module directly. + +## Live updates + +For real-time inbox refresh, hook `NotificationSentEvent` and republish through [Broadcasting](/guide/broadcasting): + +```csharp +public sealed class NotificationBroadcastHandler(IBroadcaster broadcaster) +{ + public Task Handle(NotificationSentEvent evt, CancellationToken ct) => + broadcaster.ToUserAsync( + evt.UserId.ToString(), + "notifications.sent", + new { evt.NotificationType, evt.Channel }, + ct); +} +``` + +The UI can subscribe with `useEvent(BroadcastChannels.ForUser(userId), 'notifications.sent', ...)` and refresh its inbox query when the event fires. + +## Next Steps + +- [Events](/guide/events) — durable event delivery the channels rely on. +- [Broadcasting](/guide/broadcasting) — push new inbox items to the browser in real time. +- [Background Jobs](/guide/background-jobs) — the queue behind `SendAsync`. diff --git a/docs/site/guide/rate-limiting.md b/docs/site/guide/rate-limiting.md new file mode 100644 index 00000000..55b6f9ed --- /dev/null +++ b/docs/site/guide/rate-limiting.md @@ -0,0 +1,80 @@ +--- +outline: deep +--- + +# Rate Limiting + +SimpleModule wires ASP.NET Core's built-in rate limiter into the request pipeline and lets you author policies in two places: in code, for endpoints whose limits are part of the framework contract, and in the database, for limits an administrator should tune at runtime without redeploying. + +## Applying a policy to an endpoint + +Use the `RateLimit(policyName)` extension on any endpoint convention builder: + +```csharp +using SimpleModule.Core.RateLimiting; + +public sealed class CreateEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder app) => + app.MapPost("/products", Handle) + .RateLimit(RateLimitPolicies.FixedDefault); +} +``` + +The constants in `RateLimitPolicies` are the canonical names of the policies the framework ships with, so a rename can never silently miss a call site: + +| Constant | Wire name | Notes | +|---|---|---| +| `FixedDefault` | `fixed-default` | Sensible default for most read/write endpoints. | +| `SlidingStrict` | `sliding-strict` | Tighter sliding window for hot paths. | +| `TokenBucket` | `token-bucket` | Bursty workloads (file uploads, batch ingestion). | +| `AuthStrict` | `auth-strict` | Applied to OpenIddict login/refresh endpoints by default. | + +## DB-defined rules + +Each shipped policy is backed by a row in the `RateLimiting_Rules` table. An administrator can change the limit, window, or target without a deploy — `RateLimitRuleCache` reloads the live rules and rebuilds the runtime partitioner. + +A rule has these knobs (`modules/RateLimiting/src/SimpleModule.RateLimiting.Contracts/RateLimitRule.cs`): + +| Field | Type | Description | +|---|---|---| +| `PolicyName` | `string` | The wire name applied via `.RateLimit("...")`. | +| `PolicyType` | `FixedWindow` \| `SlidingWindow` \| `TokenBucket` | Algorithm. | +| `Target` | `Ip` \| `User` \| `IpAndUser` \| `Global` | What to partition on. | +| `PermitLimit` | `int` | Requests allowed per window (fixed/sliding). | +| `WindowSeconds` | `int` | Window length in seconds. | +| `SegmentsPerWindow` | `int` | Sliding-window resolution. | +| `TokenLimit` | `int` | Bucket size (token bucket). | +| `TokensPerPeriod` | `int` | Refill rate (token bucket). | +| `ReplenishmentPeriodSeconds` | `int` | Refill period (token bucket). | +| `QueueLimit` | `int` | Requests parked when capacity is exhausted. | +| `EndpointPattern` | `string?` | Optional route pattern to narrow the rule to a subset of endpoints. | +| `IsEnabled` | `bool` | Toggle without deleting. | + +## Admin UI + +The RateLimiting module mounts an admin view at `/rate-limiting/manage` and exposes the policy CRUD over `/api/rate-limiting/`. The endpoints are protected by `RateLimiting.View`, `RateLimiting.Create`, `RateLimiting.Update`, and `RateLimiting.Delete` permissions; the active-policies preview at `/api/rate-limiting/active` is read-only. + +## Querying from other modules + +If a module needs to read or mutate rules programmatically, depend on the contract: + +```csharp +public interface IRateLimitingContracts +{ + Task> GetAllRulesAsync(); + Task GetRuleByIdAsync(RateLimitRuleId id); + Task CreateRuleAsync(CreateRateLimitRuleRequest request); + Task UpdateRuleAsync(RateLimitRuleId id, UpdateRateLimitRuleRequest request); + Task DeleteRuleAsync(RateLimitRuleId id); +} +``` + +## Response headers + +When a policy rejects a request the framework returns `429 Too Many Requests`. The `RateLimitHeaderMiddleware` adds standard `Retry-After` and the remaining-budget headers based on the configured policy so well-behaved clients can back off without guessing. + +## Next Steps + +- [Configuration](/reference/configuration) — built-in policy defaults and storage tuning. +- [Endpoints](/guide/endpoints) — the `.RateLimit(...)` extension in context. diff --git a/docs/site/guide/soft-delete.md b/docs/site/guide/soft-delete.md index e2cbcc90..5e983e18 100644 --- a/docs/site/guide/soft-delete.md +++ b/docs/site/guide/soft-delete.md @@ -59,7 +59,7 @@ var trashed = await db.Customers `WithTrashed` / `OnlyTrashed` ignore only the soft-delete filter. The multi-tenant filter (and any other named filter) stays active, so tenant isolation is preserved when admins browse the trash. ::: -## ISoftDeleteService +## ISoftDeleteService\ For per-row recovery operations, register `ISoftDeleteService` and inject it into your endpoints or services.