Skip to content
Merged
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
112 changes: 19 additions & 93 deletions docs/missing-cross-cutting-modules.md
Original file line number Diff line number Diff line change
@@ -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<T>` 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.
4 changes: 4 additions & 0 deletions docs/site/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand All @@ -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' },
],
Expand Down
27 changes: 26 additions & 1 deletion docs/site/frontend/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -152,6 +152,31 @@ function CreateForm() {
}
```

### Button Variants

```tsx
import { Button } from '@simplemodule/ui';

<Button>Save</Button> {/* variant="primary" by default */}
<Button variant="secondary">Cancel</Button>
<Button variant="ghost">Dismiss</Button>
<Button variant="danger">Delete</Button>
<Button variant="outline">Learn more</Button>

<Button size="sm">Compact</Button>
<Button size="lg">Hero CTA</Button>
```

`asChild` forwards the variant styles to a child element (useful with `<Link>`):

```tsx
import { Link } from '@inertiajs/react';

<Button asChild>
<Link href="/customers/new">New customer</Link>
</Button>
```

### Data Grid Page

```tsx
Expand Down
4 changes: 4 additions & 0 deletions docs/broadcasting.md → docs/site/guide/broadcasting.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
---
outline: deep
---

# Broadcasting

Real-time push from server to browser, layered on SignalR.
Expand Down
7 changes: 4 additions & 3 deletions docs/site/guide/events.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions docs/site/guide/file-storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
97 changes: 97 additions & 0 deletions docs/site/guide/identity.md
Original file line number Diff line number Diff line change
@@ -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<IAccountUnlockEmailSender, MailgunAccountUnlockEmailSender>();
```

## 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<IReadOnlyList<UserSessionDto>> GetActiveSessionsForUserAsync(
string userId,
string? currentTokenId,
CancellationToken cancellationToken = default);

Task<RevokeSessionResult> 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.
Loading
Loading