diff --git a/CLAUDE.md b/CLAUDE.md
index e275e1fc..8496a512 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -83,11 +83,20 @@ Test stack: xUnit.v3, FluentAssertions, Bogus, Microsoft.AspNetCore.Mvc.Testing.
3. React ClientApp dynamically imports module's `pages.js` bundle
4. Component hydrates with server-provided props
+## Module Rules & Architecture
+
+See [docs/CONSTITUTION.md](docs/CONSTITUTION.md) for the authoritative reference on:
+- Module boundaries, dependencies, and data ownership
+- Communication patterns (contracts and events)
+- Endpoint, frontend, and authorization rules
+- Compiler-enforced diagnostics (SM0001-SM0043)
+- Framework contributor guidelines
+
## Key Constraints
- **Source generator must target netstandard2.0** with `IIncrementalGenerator` (not `ISourceGenerator`).
- Modules need ``.
-- **Module Vite builds use library mode** — externalize React, React-DOM, @inertiajs/react. Inline dynamic imports.
+- **Module Vite builds use library mode** — externalize React, React-DOM, @inertiajs/react.
- **TreatWarningsAsErrors is enabled** globally via `Directory.Build.props` with `AnalysisLevel=latest-all` and `AnalysisMode=All`. Suppressed rules are listed in `.editorconfig`.
## C# Conventions (enforced by .editorconfig)
@@ -96,61 +105,6 @@ Test stack: xUnit.v3, FluentAssertions, Bogus, Microsoft.AspNetCore.Mvc.Testing.
- **Style**: File-scoped namespaces (error), usings outside namespace (error), prefer `var`.
- **Tests**: Underscore method names allowed (`Method_Scenario_Expected`). CA2234 (Uri overload) and xUnit1051 (CancellationToken) suppressed in test projects.
-## Module Communication
-
-- **Contracts pattern** — each module has a `.Contracts` project with a public interface (e.g., `IProductContracts`) and `[Dto]` types. Other modules depend on contracts, never implementations.
-- **Event bus** — `IEventBus.PublishAsync()` broadcasts to all `IEventHandler` implementations. Handler failures are isolated (collected in `AggregateException`).
-
-### Event Handler Patterns & Exception Isolation
-
-The EventBus implements **partial success semantics**: all handlers execute sequentially in registration order, even if some fail. Exceptions are collected and rethrown as `AggregateException` after all handlers complete.
-
-**Handler Execution Guarantees:**
-
-- All registered `IEventHandler` implementations execute for each event
-- Handlers run in registration order
-- If handler A throws, handler B still runs
-- Side effects from successful handlers are preserved
-- All exceptions are collected and thrown together as `AggregateException`
-
-**Handler Implementation Best Practices:**
-
-- **Stateless**: Avoid storing mutable state; handlers may be called concurrently in future versions
-- **Independent**: Don't rely on side effects from other handlers; they may execute in any order or be skipped
-- **No throwing for expected failures**: Use result types or early returns instead; thrown exceptions interrupt and must be handled by the caller
-- **Idempotent when possible**: The same event may be reprocessed in retry scenarios; design handlers to handle duplicate calls gracefully
-- **Avoid long-running work**: For expensive operations, use background jobs or a reliable message queue instead of synchronous handlers
-
-**Exception Handling Pattern:**
-
-```csharp
-public sealed class AuditLogEventHandler : IEventHandler
-{
- public async Task HandleAsync(OrderCreatedEvent @event, CancellationToken cancellationToken)
- {
- try
- {
- // Log the event
- await _logger.LogEventAsync(@event, cancellationToken);
- }
- catch (Exception ex)
- {
- // Don't throw: audit logging must never disrupt the primary operation
- _logger.LogError(ex, "Failed to log event");
- // Optionally: queue for retry, alert, etc.
- }
- }
-}
-```
-
-**Testing Partial Failure:**
-Use `EventBusPartialFailureTests` as a reference. Key scenarios:
-
-- Verify successful handlers complete their work even if others fail
-- Verify exceptions are collected and rethrown as `AggregateException`
-- Verify handler execution order is preserved even when some fail
-- Verify cancellation tokens propagate to all handlers
-
## Pages Registry Pattern (Pages/index.ts)
When you add a new `IViewEndpoint`, you **must** register it in your module's `Pages/index.ts` immediately. This is a manual, critical step.
@@ -203,55 +157,6 @@ sm new feature # add feature to existing module
sm doctor [--fix] # validate project structure, auto-fix issues
```
-## Database
-
-- Multi-provider: SQLite (table prefixes), PostgreSQL/SQL Server (schemas per module).
-- Each module registers `ModuleDbContextInfo` for schema isolation.
-- Uses `EnsureCreated()` — for production migrations, use EF Core migrations per module.
-
-### EF Core Interceptor DI Patterns
-
-**Problem:** SaveChangesInterceptors registered in DI can cause circular dependencies when they depend on services that themselves depend on DbContext. For example: `SaveChangesInterceptor → ISettings → SettingsService → SettingsDbContext` creates a deadlock during construction.
-
-**Solution:** Resolve runtime dependencies at interception time, not in the constructor.
-
-**Pattern:**
-
-```csharp
-// CORRECT: Constructor has minimal dependencies (only required for interceptor itself)
-public sealed class MyInterceptor(
- IServiceProvider? serviceProvider = null // Injected via DI, nullable for optionality
-) : SaveChangesInterceptor
-{
- public override ValueTask> SavingChangesAsync(
- DbContextEventData eventData,
- InterceptionResult result,
- CancellationToken cancellationToken = default
- )
- {
- // Resolve optional services at interception time
- var settings = serviceProvider?.GetService();
- if (settings is not null)
- {
- // Use the service
- }
- return base.SavingChangesAsync(eventData, result, cancellationToken);
- }
-}
-
-// WRONG: Constructor tries to resolve ISettingsContracts
-public sealed class BadInterceptor(
- ISettingsContracts settings // WRONG: Causes circular dependency!
-) : SaveChangesInterceptor { }
-```
-
-**Key Guidelines:**
-
-- Never inject services that transitively depend on DbContext into interceptor constructors
-- Inject `IServiceProvider?` as an optional dependency if runtime service resolution is needed
-- Resolve services only within `SavingChangesAsync`, `SavedChangesAsync`, or `SaveChangesFailed` methods
-- The framework resolves all registered `ISaveChangesInterceptor` instances lazily during DbContext options construction
-
## Adding a New Module
Use `sm new module ` (CLI) or manually:
@@ -274,94 +179,6 @@ Use `sm new module ` (CLI) or manually:
5. Add `ProjectReference` to `template/SimpleModule.Host/SimpleModule.Host.csproj`
6. Add all projects to `SimpleModule.slnx`
-## Minimal API Parameter Binding
-
-Reference: https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/parameter-binding?view=aspnetcore-10.0
-
-### Binding Source Priority
-
-| HTTP method | Binding order (implicit) |
-|-------------|--------------------------|
-| GET, HEAD, OPTIONS, DELETE | Route → Query → Header → DI services |
-| POST, PUT, PATCH | Route → Query → Header → Body (JSON) → DI services |
-
-**Key rule:** GET/HEAD/OPTIONS/DELETE never implicitly bind from body. Use `[FromBody]` explicitly if needed.
-
-### Implicit Binding (no attribute needed)
-
-- **Simple types** (`int`, `string`, etc.): Route → Query (implicit)
-- **Complex types** (classes/records): Body (JSON) for POST/PUT/DELETE (implicit)
-- **DI services**: Auto-injected when registered in the container. `[FromServices]` is noise.
-- **Special types**: `HttpContext`, `HttpRequest`, `HttpResponse`, `CancellationToken`, `ClaimsPrincipal`, `Stream`, `PipeReader` are auto-bound
-- **Arrays from query strings**: `int[]`, `string[]`, `StringValues` bind from repeated keys: `?q=1&q=2` → `int[] q`
-
-### Optional Parameters
-
-Parameters are **required by default**. Missing required params → 400 Bad Request. Make optional via:
-- Nullable type: `int? pageNumber` (null if missing)
-- Default value: `int pageNumber = 1` (uses default if missing)
-- `string[]` from query: missing key → empty array (not null)
-
-### When to Use Attributes
-
-- `[FromForm]` — **required** for scalar form data (never implicit). Supports scalars, enums, complex types, `IFormFile`.
-- `[FromForm]` on complex types — binds class properties from form fields: `([FromForm] Todo todo) => ...`
-- `[FromBody]` — explicit body binding (usually implicit for complex types in POST/PUT)
-- `[FromQuery]` — when a parameter name conflicts with a route parameter, or to rename: `[FromQuery(Name = "p")]`
-- `[FromRoute]` — when disambiguation needed
-- `[FromHeader(Name = "X-Header")]` — for HTTP headers. Supports `int[]`/`string[]` from repeated headers.
-- `[AsParameters]` — bind a struct/class from multiple sources (route + query + header + DI)
-
-### Form Binding Limitations (IMPORTANT)
-
-`[FromForm]` supports:
-- ✅ Scalar types, enums, complex types, `IFormFile`/`IFormFileCollection`
-- ❌ **`List`, `string[]` from repeated form keys** — does NOT work in Minimal APIs
-
-For multi-value form fields (e.g., `permissions=A&permissions=B`), use `ReadFormAsync()`:
-```csharp
-var form = await context.Request.ReadFormAsync();
-var permissions = form["permissions"].Where(p => !string.IsNullOrWhiteSpace(p)).Select(p => p!).ToList();
-```
-
-### Correct Patterns
-
-```csharp
-// API: complex type auto-binds from JSON body, service auto-injected
-app.MapPost("/", async (CreateProductRequest request, IProductContracts products) => ...);
-
-// API: route param + body + DI
-app.MapPut("/{id}", async (int id, UpdateProductRequest request, IProductContracts products) => ...);
-
-// View: scalar form data requires [FromForm]
-app.MapPost("/", async ([FromForm] string name, [FromForm] decimal price, IProductContracts products) => ...);
-
-// View: complex type from form
-app.MapPost("/todo", async ([FromForm] Todo todo) => TypedResults.Ok(todo));
-
-// Query: arrays bind from repeated keys
-app.MapGet("/tags", (int[] q) => $"tag1: {q[0]}, tag2: {q[1]}");
-```
-
-### Anti-patterns (Avoid)
-
-```csharp
-// BAD: manual form reading for SCALAR values (use [FromForm] instead)
-app.MapPost("/", async (HttpContext context, IService svc) => {
- var form = await context.Request.ReadFormAsync();
- var name = form["name"].ToString(); // Use [FromForm] string name
-});
-
-// BAD: manual JSON deserialization (let model binding handle it)
-app.MapPost("/", async (HttpContext context, IService svc) => {
- var body = await JsonSerializer.DeserializeAsync(context.Request.Body);
-});
-
-// OK: ReadFormAsync for multi-value form fields (no [FromForm] equivalent)
-var form = await context.Request.ReadFormAsync();
-var permissions = form["permissions"].ToList(); // This is the correct approach
-```
-
## Linting & Formatting
Biome is configured at repo root (`biome.json`). Covers `modules/**`, `packages/**`, `template/**` except `**/wwwroot/**`. Settings: single quotes, semicolons always, 2-space indent, trailing commas, 100-char line width. Tailwind CSS directives enabled.
diff --git a/SimpleModule.slnx b/SimpleModule.slnx
index 59c8f348..c004fc10 100644
--- a/SimpleModule.slnx
+++ b/SimpleModule.slnx
@@ -21,6 +21,7 @@
+
diff --git a/docs/CONSTITUTION.md b/docs/CONSTITUTION.md
new file mode 100644
index 00000000..b87dcaad
--- /dev/null
+++ b/docs/CONSTITUTION.md
@@ -0,0 +1,462 @@
+# Module Constitution
+
+The authoritative rules for building modules on SimpleModule and contributing to the framework itself. This document governs architectural decisions, module boundaries, data ownership, communication patterns, and compiler-enforced constraints.
+
+**Audience:** Developers building modules and contributors to the framework (Core, Generator, Database).
+
+**Assumption:** This is a small team deploying everything as one unit. Modules exist for code clarity and ownership, not for independent deployment or microservice extraction.
+
+---
+
+## 1. Founding Principles
+
+- **Small team, single deployment.** All modules deploy together. One migration history, one release, one rollback.
+- **Shared database.** All modules share one database via the unified HostDbContext. Schema and table-prefix isolation is for cleanliness, not independence. This is a deliberate design choice that simplifies operations and enables cross-module queries when needed.
+- **Modular for clarity, not for microservices.** Boundaries organize code, enforce ownership, and prevent spaghetti. They are not for independent scaling. The contracts pattern makes future extraction possible, but do not design for it now.
+- **Compile-time safety over runtime discipline.** If a rule can be enforced by the source generator (SM diagnostics), it must be. Do not rely on code review for what the compiler can catch.
+- **Convention over configuration.** Modules follow predictable patterns so the codebase reads like one person wrote it.
+
+---
+
+## 2. Module Boundaries
+
+### What a Module Owns
+
+- Its **implementation assembly** (`SimpleModule.{Name}`) -- entities, services, DbContext, endpoints, views, event handlers
+- Its **contracts assembly** (`SimpleModule.{Name}.Contracts`) -- the public API surface: contract interface, DTOs, request/response types, value objects, events, permissions
+- Its **frontend assets** -- React components, Vite config, page registry
+- Its **menu items** -- registered via `ConfigureMenu`
+
+### What a Module Exposes
+
+Only what is in Contracts. Everything else is internal.
+
+- One primary contract interface (`I{Name}Contracts`)
+- DTOs and request types marked with `[Dto]` (auto-generates TypeScript types)
+- Value object IDs via Vogen with EF Core converters (Vogen is an allowed dependency in Contracts projects)
+- Events implementing `IEvent`
+- Permission constants via `IModulePermissions` (place in Contracts if other modules need to reference them)
+
+### Module Lifecycle Hooks
+
+All hooks are optional. All have default no-op implementations.
+
+1. **ConfigureServices** -- register DI services, DbContext, hosted services
+2. **ConfigureEndpoints** -- escape hatch for non-standard routes (standard endpoints are auto-discovered)
+3. **ConfigureMiddleware** -- register ASP.NET middleware
+4. **ConfigureMenu** -- register navigation menu items
+5. **ConfigurePermissions** -- register authorization permissions
+6. **ConfigureSettings** -- register runtime-configurable settings
+7. **OnStartAsync** -- one-time initialization after all services are registered
+8. **OnStopAsync** -- graceful shutdown cleanup
+9. **CheckHealthAsync** -- report module health status (Healthy, Degraded, Unhealthy)
+
+### What a Module Must Never Expose
+
+- Entity classes
+- DbContext or DbSet types
+- Internal services
+- EF Core configurations
+
+### Structural Rules
+
+- Every module has both a Contracts project and an implementation project.
+- The module class is decorated with `[Module]` and implements `IModule`.
+- Module name, route prefix, and view prefix should be defined in a constants class (convention, not enforced by diagnostic).
+- One module, one DbContext (if data is needed).
+- SM0043 warns if a module class overrides no lifecycle hooks (indicating a likely empty or placeholder module).
+
+---
+
+## 3. Dependencies
+
+### Allowed
+
+- Module implementation --> own Contracts project
+- Module implementation --> other modules' Contracts projects (never their implementations)
+- Module implementation --> framework projects (Core, Database)
+- Contracts --> Core only (plus Vogen for value objects)
+
+### Forbidden
+
+- Module --> another module's implementation (SM0011, error)
+- Circular contract references (SM0010, error)
+- Contracts --> Database or any framework project beyond Core
+
+### Dependency Direction Principle
+
+Dependencies flow one way: **implementation --> contracts --> core**. Never sideways (implementation --> implementation). Never backwards (contracts --> implementation).
+
+### Resolving Circular Dependencies
+
+- Use the event bus: module B publishes an event, module A handles it. No reference needed.
+- Extract shared concepts into a third Contracts project, or rethink ownership.
+
+### DI Injection Rules
+
+- Inject contract interfaces, never concrete services.
+- Framework services (`IEventBus`, `ISettingsContracts`) are injected directly.
+- DbContext is injected only within the owning module's implementation.
+
+---
+
+## 4. Data Ownership
+
+### Each Module Owns Its Data Exclusively
+
+- Entities live in the implementation assembly, never in Contracts.
+- Only the owning module's service layer may read or write its entities.
+- Other modules access data through the contract interface.
+
+### DbContext Rules
+
+- Register with `AddModuleDbContext(configuration, ModuleName)`.
+- Call `ApplyModuleSchema()` in `OnModelCreating`.
+- Use entity configurations via `IEntityTypeConfiguration`.
+- Register Vogen value object converters in `ConfigureConventions`.
+- One DbContext per module, maximum.
+
+### Entity Configuration
+
+- One configuration per entity (SM0007 prevents duplicates).
+- No entity in two modules' DbSets with different types (SM0001).
+- Orphaned configurations warned (SM0006).
+
+### Schema Isolation
+
+Schema isolation is automatic and provider-dependent:
+
+- **PostgreSQL / SQL Server:** schema per module (lowercase name)
+- **SQLite:** table prefix per module
+
+This is cosmetic organization -- all modules share one connection.
+
+### Forbidden
+
+- Injecting HostDbContext in module code
+- Raw SQL referencing another module's tables
+- Foreign keys across module boundaries -- use IDs and validate via contracts
+- Sharing entity types between modules -- use DTOs in Contracts for shared shapes
+
+---
+
+## 5. Communication
+
+### Contract Interfaces
+
+- One primary interface per module: `I{Name}Contracts`
+- The only way other modules interact with your data or behavior
+- SM0012 warns at 15+ methods, SM0013 errors at 20+
+- Exactly one implementation required (SM0025/SM0026), public and non-abstract (SM0028/SM0029)
+
+### Events
+
+- Cross-module notifications use `IEventBus.PublishAsync()`
+- Events are defined in the publishing module's Contracts project
+- Any module can handle any event via `IEventHandler`
+- Handlers currently execute sequentially in registration order; all run even if some fail. Design handlers as order-independent for forward compatibility.
+- Failures are collected into `AggregateException`
+- Handlers should be stateless, independent, and idempotent
+
+### PublishAsync vs PublishInBackground
+
+- **`PublishAsync()`** -- synchronous dispatch. The caller blocks until all handlers complete. Exceptions propagate to the caller.
+- **`PublishInBackground()`** -- fire-and-forget. Failures are logged, not thrown. No cancellation token is accepted, so callers cannot cancel in-flight background work, and handlers may be interrupted on host shutdown. Use for notifications where the caller must not block or fail (audit logging, cache invalidation).
+
+### When to Use Which Communication Pattern
+
+- **Contracts** -- caller needs a response
+- **Events** -- caller does not care who listens
+
+### Forbidden
+
+- Direct service injection across modules (only contract interfaces)
+- Shared mutable state
+- Database-level integration (triggers, views, cross-module queries)
+
+---
+
+## 6. Endpoints
+
+### Two Types
+
+- **`IEndpoint`** -- API endpoints returning JSON
+- **`IViewEndpoint`** -- view endpoints returning `Inertia.Render()`
+
+### Routing
+
+- API endpoints live under `RoutePrefix`, view endpoints under `ViewPrefix`.
+- View page names must match the module name prefix (SM0041).
+- No duplicate view page names (SM0015).
+- Modules with view endpoints must define `ViewPrefix` (SM0042).
+
+### REST Conventions
+
+- **GET** for reads
+- **POST** for creates
+- **PUT** for updates
+- **DELETE** for deletes
+- Use `CrudEndpoints` helpers for consistent status codes: 200 (OK), 201 (Created), 204 (No Content), 404 (Not Found)
+- For non-standard responses, write custom handlers.
+
+### Parameter Binding
+
+- Implicit binding by default -- do not manually read request body or form data for scalars.
+- `[FromForm]` is required for form submissions.
+- `ReadFormAsync()` only for multi-value form fields (arrays from repeated keys).
+- `[FromServices]` is unnecessary noise; DI services are injected automatically.
+
+### Authorization
+
+- `.RequirePermission()` with permission constants.
+- `.AllowAnonymous()` for public endpoints.
+- Default is authorized.
+
+### Escape Hatch
+
+Override `ConfigureEndpoints` on the module class for non-standard routes.
+
+---
+
+## 7. Frontend
+
+### Page Registry
+
+- Every `IViewEndpoint` must have a matching entry in `Pages/index.ts`.
+- The key must match the component name passed to `Inertia.Render()`.
+- Missing entries cause silent client-side 404s with no error in the console.
+- Run `npm run validate-pages` to verify all endpoints have matching page entries.
+
+### Dynamic Imports
+
+- **Within a module build:** Dynamic imports follow Rollup's default behavior (code splitting is supported within the module's bundle).
+- **Between modules:** ClientApp lazy-loads each module's `pages.js` bundle at runtime. This is how the system works -- each module is a separate entry point.
+
+### Build System
+
+- Each module has a `vite.config.ts` using `defineModuleConfig()`.
+- Output: `{ModuleName}.pages.js` in `wwwroot/`.
+- Library mode -- React, React-DOM, and @inertiajs/react are externalized.
+
+### TypeScript Types
+
+- `[Dto]` types auto-generate TypeScript interfaces via the source generator.
+- Do not hand-write types that mirror DTOs.
+
+### Package Structure
+
+- React and Inertia declared as `peerDependencies`.
+- Use `@simplemodule/ui` for shared components.
+- Use `@simplemodule/theme-default` for styling.
+
+### Linting and Formatting
+
+- Biome configured at repo root.
+- Single quotes, semicolons, 2-space indent, trailing commas, 100-char line width.
+- `npm run check` to verify, `npm run check:fix` to auto-fix.
+
+---
+
+## 8. Permissions & Authorization
+
+### Defining Permissions
+
+- Sealed class implementing `IModulePermissions` (SM0032 enforces sealed).
+- `public const string` fields only (SM0027).
+- Values follow the pattern `{ModuleName}.{Action}` (SM0031, SM0034).
+- No duplicate values (SM0033).
+
+### Registering Permissions
+
+- Override `ConfigurePermissions` on the module class and call `builder.AddPermissions()`.
+
+### Applying Permissions
+
+- `.RequirePermission()` on endpoints.
+- `.AllowAnonymous()` for public endpoints.
+- Never hardcode permission strings -- always use the permission constants.
+
+### Rules
+
+- Permissions are owned by the defining module.
+- Other modules may reference permission constants from Contracts.
+- Not every module needs permissions.
+
+---
+
+## 9. Settings & Configuration
+
+### Defining Settings
+
+Override `ConfigureSettings` on the module class.
+
+### Scopes
+
+| Scope | Purpose |
+|-------|---------|
+| **System** | Global settings that affect infrastructure (database, caching, external service configuration) |
+| **Application** | Application-wide preferences visible to all users (site title, default language) |
+| **User** | Per-user preferences (theme, notification settings) |
+
+### Types
+
+| Type | Description |
+|------|-------------|
+| **Text** | String value (single-line) |
+| **Number** | Numeric value (integer or decimal) |
+| **Bool** | Boolean (true/false) |
+| **Json** | Arbitrary JSON value (for complex or structured settings) |
+
+### Rules
+
+- Namespace setting keys to the module.
+- Settings are only for values configurable at runtime.
+- For per-environment values, use `IConfiguration` and `appsettings.json`.
+- Access other modules' settings through `ISettingsContracts`.
+
+---
+
+## 10. Testing
+
+### Stack
+
+xUnit.v3, FluentAssertions, Bogus, `SimpleModuleWebApplicationFactory`.
+
+### Structure
+
+- Test project per module at `modules/{Name}/tests/{Name}.Tests/`.
+- Underscore method naming: `Method_Scenario_Expected`.
+
+### Integration Tests
+
+- `SimpleModuleWebApplicationFactory` provides in-memory SQLite and a test auth scheme.
+- `CreateAuthenticatedClient(params Claim[] claims)` for authenticated requests.
+
+### Fake Data
+
+- `FakeDataGenerators` provides pre-built Bogus fakers for all module DTOs and request types.
+
+### Database
+
+- SQLite in-memory locally, PostgreSQL in CI.
+- Both providers tested in CI.
+
+### Rules
+
+- Test through public API (endpoints and contract interfaces).
+- Do not mock the database.
+- Test cross-module interactions through contracts.
+- Every module should have tests.
+
+---
+
+## 11. Compiler-Enforced Rules
+
+All SM diagnostics are emitted by the Roslyn source generator at compile time. `TreatWarningsAsErrors` is enabled globally, so warnings are effectively errors unless suppressed in `.editorconfig`.
+
+### Database & Entities
+
+| Diagnostic | Severity | Rule |
+|------------|----------|------|
+| SM0001 | Error | No duplicate DbSet property names across modules |
+| SM0003 | Error | Only one IdentityDbContext allowed |
+| SM0005 | Error | IdentityDbContext must use three type arguments |
+| SM0006 | Warning | Orphaned entity configuration (not referenced by any DbSet) |
+| SM0007 | Error | No duplicate entity configurations |
+
+### Module Structure
+
+| Diagnostic | Severity | Rule |
+|------------|----------|------|
+| SM0002 | Warning | Module name must not be empty |
+| SM0040 | Error | No duplicate module names |
+| SM0043 | Warning | Module must override at least one IModule method |
+
+### Dependencies
+
+| Diagnostic | Severity | Rule |
+|------------|----------|------|
+| SM0010 | Error | No circular module dependencies |
+| SM0011 | Error | No direct module-to-module implementation references |
+
+### Contracts
+
+| Diagnostic | Severity | Rule |
+|------------|----------|------|
+| SM0012 | Warning | Contract interface has 15+ methods (consider splitting) |
+| SM0013 | Error | Contract interface has 20+ methods (must split) |
+| SM0014 | Error | Contracts assembly has no public interfaces |
+| SM0025 | Error | No implementation found for contract interface |
+| SM0026 | Error | Multiple implementations of contract interface |
+| SM0028 | Error | Contract implementation must be public |
+| SM0029 | Error | Contract implementation must not be abstract |
+| SM0035 | Warning | DTO with no public properties |
+| SM0038 | Warning | Infrastructure type found in Contracts assembly |
+
+### Permissions
+
+| Diagnostic | Severity | Rule |
+|------------|----------|------|
+| SM0027 | Error | Permission field must be const string |
+| SM0031 | Warning | Permission value must follow the `Module.Action` pattern (exactly one dot) |
+| SM0032 | Error | Permission class must be sealed |
+| SM0033 | Error | No duplicate permission values |
+| SM0034 | Warning | Permission value prefix must match the owning module name |
+
+### View Endpoints
+
+| Diagnostic | Severity | Rule |
+|------------|----------|------|
+| SM0015 | Error | No duplicate view page names |
+| SM0041 | Warning | View page name must be prefixed with module name |
+| SM0042 | Error | ViewPrefix required when module has view endpoints |
+
+### Interceptors
+
+| Diagnostic | Severity | Rule |
+|------------|----------|------|
+| SM0039 | Warning | Interceptor has transitive DbContext dependency (resolve at interception time) |
+
+---
+
+## 12. Framework Contributor Guidelines
+
+### Source Generator
+
+- Target netstandard2.0 with `IIncrementalGenerator`.
+- Use incremental pipeline for caching.
+- New diagnostics use the next available SM number after the highest existing one.
+- All diagnostics must have tests.
+
+### Adding a Diagnostic
+
+1. Define the descriptor in `DiagnosticEmitter.cs`.
+2. Add detection logic in discovery or emission.
+3. Add positive and negative test cases.
+4. Document in this Constitution, Section 11.
+5. Use Error severity for runtime breakage, Warning for code smells.
+
+### HostDbContext Generation
+
+- The generator merges all module DbSets into one context.
+- Entity-to-module mapping drives schema isolation.
+- Test against all modules before merging changes.
+
+### Database Framework
+
+- `ModuleDbContextOptionsBuilder` handles provider detection and routing.
+- Interceptors are resolved lazily to avoid circular DI.
+- `ApplyModuleSchema` must handle PostgreSQL, SQL Server, and SQLite.
+
+### Core Framework
+
+- All `IModule` methods must have default implementations.
+- New lifecycle hooks require a default no-op.
+- Keep Contracts dependencies minimal.
+
+### Build and CI
+
+- `TreatWarningsAsErrors` is enabled globally via `Directory.Build.props`.
+- `AnalysisLevel=latest-all`, `AnalysisMode=All`.
+- Suppressed rules live in `.editorconfig`.
+- Tests run against both SQLite and PostgreSQL in CI.
diff --git a/docs/superpowers/plans/2026-03-27-module-constitution.md b/docs/superpowers/plans/2026-03-27-module-constitution.md
new file mode 100644
index 00000000..a400079f
--- /dev/null
+++ b/docs/superpowers/plans/2026-03-27-module-constitution.md
@@ -0,0 +1,119 @@
+# Module Constitution Implementation Plan
+
+> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Write the Module Constitution document and trim CLAUDE.md to reference it for architectural rules.
+
+**Architecture:** Two files change: create `docs/CONSTITUTION.md` (the authoritative rules document), then edit `CLAUDE.md` to remove sections now covered by the Constitution and add a reference link.
+
+**Tech Stack:** Markdown only. No code changes.
+
+**Spec:** `docs/superpowers/specs/2026-03-27-module-constitution-design.md`
+
+---
+
+## Chunk 1: Write the Constitution
+
+### Task 1: Write docs/CONSTITUTION.md
+
+**Files:**
+
+- Create: `docs/CONSTITUTION.md`
+
+**Source material:** The spec at `docs/superpowers/specs/2026-03-27-module-constitution-design.md` contains the full content for all 12 sections. Write it as a clean, final document — not a spec format.
+
+- [ ] **Step 1: Write the Constitution document**
+
+Create `docs/CONSTITUTION.md` with all 12 sections from the spec. Format as a rules document, not a spec. Use clear headers, bullet points, and tables. No code examples (per design decision). The document should read as the authoritative reference for module development and framework contribution.
+
+**Important framing note:** The existing CLAUDE.md calls the shared database a "Known Limitation." The Constitution reframes this as a deliberate design choice (spec Section 1, line 29). Do not carry over the limitation framing.
+
+Sections to include (all content is in the spec):
+1. Founding Principles
+2. Module Boundaries (including all 9 lifecycle hooks)
+3. Dependencies (including Vogen exception for Contracts)
+4. Data Ownership
+5. Communication (including forward-compat handler ordering note, PublishAsync vs PublishInBackground guidance)
+6. Endpoints (including specific REST conventions and status codes)
+7. Frontend (including corrected dynamic imports rule, within-module vs between-module)
+8. Permissions & Authorization
+9. Settings & Configuration (including scope definitions)
+10. Testing
+11. Compiler-Enforced Rules (SM diagnostic table)
+12. Framework Contributor Guidelines (using "highest existing SM number")
+
+- [ ] **Step 2: Verify the document**
+
+Read the written file and confirm:
+- All 12 sections present
+- All SM diagnostics listed (SM0001 through SM0043, excluding gaps)
+- All 9 IModule lifecycle hooks documented
+- No code examples
+- Consistent formatting
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add docs/CONSTITUTION.md
+git commit -m "Add Module Constitution — authoritative rules for module development and framework contribution"
+```
+
+---
+
+## Chunk 2: Trim CLAUDE.md
+
+### Task 2: Remove sections moved to Constitution
+
+**Files:**
+
+- Modify: `CLAUDE.md`
+
+- [ ] **Step 1: Remove the following sections from CLAUDE.md**
+
+Remove these sections entirely (their content now lives in the Constitution):
+
+- **"Module Communication"** (lines 99-102) — covered by Constitution Section 5
+- **"Event Handler Patterns & Exception Isolation"** (lines 104-152) — covered by Constitution Section 5
+- **"EF Core Interceptor DI Patterns"** (lines 227-268) — covered by Constitution Section 4 and 12
+- **"Minimal API Parameter Binding"** (lines 292-378) — covered by Constitution Section 6
+- **"Database" section** (lines 206-225, including "Unified HostDbContext" subsection) — covered by Constitution Sections 1 and 4
+
+- [ ] **Step 2: Add Constitution reference**
+
+After the "Architecture" section (after "Request Flow"), add:
+
+```markdown
+## Module Rules & Architecture
+
+See [docs/CONSTITUTION.md](docs/CONSTITUTION.md) for the authoritative reference on:
+- Module boundaries, dependencies, and data ownership
+- Communication patterns (contracts and events)
+- Endpoint, frontend, and authorization rules
+- Compiler-enforced diagnostics (SM0001–SM0043)
+- Framework contributor guidelines
+```
+
+- [ ] **Step 3: Update Key Constraints**
+
+In the "Key Constraints" section, update line 90 from:
+> Module Vite builds use library mode — externalize React, React-DOM, @inertiajs/react. Inline dynamic imports.
+
+To:
+> Module Vite builds use library mode — externalize React, React-DOM, @inertiajs/react.
+
+(Removes the incorrect "Inline dynamic imports" claim.)
+
+- [ ] **Step 4: Verify CLAUDE.md**
+
+Read the updated file and confirm:
+- Removed sections are gone
+- Constitution reference is present and links correctly
+- Remaining sections are intact: What This Is, Build & Run, Frontend, Testing, Architecture, Key Constraints, C# Conventions, Pages Registry Pattern, Test Infrastructure, Frontend Packages, CLI, Adding a New Module, Linting & Formatting, AI workflow sections (Plan Node Default through Core Principles)
+- No orphaned headers or broken formatting
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add CLAUDE.md
+git commit -m "Trim CLAUDE.md — move architectural rules to Constitution, add reference link"
+```
diff --git a/docs/superpowers/specs/2026-03-27-module-constitution-design.md b/docs/superpowers/specs/2026-03-27-module-constitution-design.md
new file mode 100644
index 00000000..0fd13770
--- /dev/null
+++ b/docs/superpowers/specs/2026-03-27-module-constitution-design.md
@@ -0,0 +1,392 @@
+# Module Constitution Design Spec
+
+**Date:** 2026-03-27
+**Status:** Draft
+**Output:** `docs/CONSTITUTION.md`
+
+## Goal
+
+Create a single authoritative document — the Constitution — that codifies all rules for building modules and contributing to the framework. Designed for a small team with a single deployment unit and shared database.
+
+After writing, CLAUDE.md will be trimmed to reference the Constitution for architectural rules, keeping only build commands, tooling, and AI-assistant instructions.
+
+## Audience
+
+- Developers building modules on top of SimpleModule
+- Contributors to the framework itself (Core, Generator, Database)
+
+## Key Assumption
+
+This is a small team deploying everything as one unit. Modules exist for code clarity and ownership, not for independent deployment or microservice extraction.
+
+## Document Structure
+
+### 1. Founding Principles
+
+The preamble — assumptions everything else builds on:
+
+- **Small team, single deployment** — all modules deploy together. One migration history, one release, one rollback.
+- **Shared database** — all modules share one database via the unified HostDbContext. Schema/table-prefix isolation is for cleanliness, not independence. Deliberate choice, not a limitation.
+- **Modular for clarity, not for microservices** — boundaries organize code, enforce ownership, prevent spaghetti. Not for independent scaling. The contracts pattern makes future extraction possible but don't design for it now.
+- **Compile-time safety over runtime discipline** — if a rule can be enforced by the source generator (SM diagnostics), it should be. Don't rely on code review for what the compiler can catch.
+- **Convention over configuration** — modules follow predictable patterns so the codebase reads like one person wrote it.
+
+### 2. Module Boundaries
+
+**What a module owns:**
+- Its implementation assembly (`SimpleModule.{Name}`) — entities, services, DbContext, endpoints, views, event handlers
+- Its contracts assembly (`SimpleModule.{Name}.Contracts`) — the public API surface: contract interface, DTOs, request/response types, value objects, events, permissions
+- Its frontend assets — React components, Vite config, page registry
+- Its menu items — registered via `ConfigureMenu`
+
+**What a module exposes:**
+- Only what's in Contracts. Everything else is internal.
+- One primary contract interface (`I{Name}Contracts`)
+- DTOs and request types marked with `[Dto]` (auto-generates TypeScript types)
+- Value object IDs via Vogen with EF Core converters (Vogen is an allowed dependency in Contracts projects)
+- Events implementing `IEvent`
+- Permission constants via `IModulePermissions`
+
+**Module lifecycle hooks (all optional, all have default no-ops):**
+- `ConfigureServices` — register DI services, DbContext, hosted services
+- `ConfigureEndpoints` — escape hatch for non-standard routes (standard endpoints are auto-discovered)
+- `ConfigureMiddleware` — register ASP.NET middleware
+- `ConfigureMenu` — register navigation menu items
+- `ConfigurePermissions` — register authorization permissions
+- `ConfigureSettings` — register runtime-configurable settings
+- `OnStartAsync` — one-time initialization after all services are registered
+- `OnStopAsync` — graceful shutdown cleanup
+- `CheckHealthAsync` — report module health status (Healthy, Degraded, Unhealthy)
+
+**What a module must never expose:**
+- Entity classes
+- DbContext or DbSet types
+- Internal services
+- EF Core configurations
+
+**Structural rules:**
+- Every module has both a Contracts project and an implementation project
+- The module class is decorated with `[Module]` and implements `IModule`
+- Module name, route prefix, and view prefix should be defined in a constants class (convention, not enforced by diagnostic)
+- One module, one DbContext (if data is needed)
+- SM0043 warns if a module class overrides nothing
+
+### 3. Dependencies
+
+**Allowed:**
+- Module → own Contracts project
+- Module → other module's Contracts project (never implementation)
+- Module → framework projects (Core, Database)
+- Contracts → Core only (plus Vogen for value objects)
+
+**Forbidden:**
+- Module → another module's implementation (SM0011, error)
+- Circular contract references (SM0010, error)
+- Contracts → Database or any framework project beyond Core
+
+**Resolving circular dependencies:**
+- Use the event bus: B publishes an event, A handles it. No reference needed.
+- Extract shared concepts into a third Contracts project, or rethink ownership.
+
+**Dependency direction principle:**
+- Dependencies flow one way: implementation → contracts → core
+- Never sideways (implementation → implementation)
+- Never backwards (contracts → implementation)
+
+**DI injection rules:**
+- Inject contract interfaces, never concrete services
+- Framework services (`IEventBus`, `ISettingsContracts`) are injected directly
+- DbContext is injected only within the owning module's implementation
+
+### 4. Data Ownership
+
+**Each module owns its data exclusively:**
+- Entities live in the implementation assembly, never in Contracts
+- Only the owning module's service layer may read or write its entities
+- Other modules access data through the contract interface
+
+**DbContext rules:**
+- Register with `AddModuleDbContext(configuration, ModuleName)`
+- Call `ApplyModuleSchema()` in `OnModelCreating`
+- Use entity configurations via `IEntityTypeConfiguration`
+- Register Vogen value object converters in `ConfigureConventions`
+- One DbContext per module, maximum
+
+**Entity configuration:**
+- One configuration per entity (SM0007 prevents duplicates)
+- No entity in two modules' DbSets with different types (SM0001)
+- Orphaned configurations warned (SM0006)
+
+**Schema isolation (automatic):**
+- PostgreSQL/SQL Server: schema per module (lowercase name)
+- SQLite: table prefix per module
+- Cosmetic organization, not security — all modules share one connection
+
+**Forbidden:**
+- Injecting HostDbContext in module code
+- Raw SQL referencing another module's tables
+- Foreign keys across module boundaries — use IDs and validate via contracts
+- Sharing entity types between modules — use DTOs in Contracts for shared shapes
+
+### 5. Communication
+
+**Contract interfaces:**
+- One primary interface per module: `I{Name}Contracts`
+- The only way other modules interact with your data or behavior
+- SM0012 warns at 15+ methods, SM0013 errors at 20+
+- Exactly one implementation required (SM0025/SM0026), public and non-abstract (SM0028/SM0029)
+
+**Events:**
+- Cross-module notifications use `IEventBus.PublishAsync()`
+- Events defined in the publishing module's Contracts project
+- Any module can handle any event via `IEventHandler`
+- Handlers currently execute sequentially in registration order, all run even if some fail. Design handlers as order-independent for forward compatibility.
+- Failures collected into `AggregateException`
+- `PublishAsync()` — synchronous dispatch, caller blocks until all handlers complete, exceptions propagate
+- `PublishInBackground()` — fire-and-forget, failures logged not thrown. Use for notifications where the caller must not block or fail (audit logging, cache invalidation).
+- Handlers should be stateless, independent, and idempotent
+
+**When to use which:**
+- Contracts — caller needs a response
+- Events — caller doesn't care who listens
+
+**Forbidden:**
+- Direct service injection across modules (only contract interfaces)
+- Shared mutable state
+- Database-level integration (triggers, views, cross-module queries)
+
+### 6. Endpoints
+
+**Two types:**
+- `IEndpoint` — API endpoints returning JSON
+- `IViewEndpoint` — view endpoints returning `Inertia.Render()`
+
+**Routing:**
+- API endpoints under `RoutePrefix`, view endpoints under `ViewPrefix`
+- Standard REST conventions: GET for reads, POST for creates, PUT for updates, DELETE for deletes. Use `CrudEndpoints` helpers for consistent status codes (200, 201, 204, 404).
+- View page names must match module name prefix (SM0041)
+- No duplicate view page names (SM0015)
+- Modules with view endpoints must define ViewPrefix (SM0042)
+
+**Parameter binding:**
+- Implicit binding by default — don't manually read request body or form data for scalars
+- `[FromForm]` required for form submissions
+- `ReadFormAsync()` only for multi-value form fields
+- `[FromServices]` is unnecessary noise
+
+**Authorization:**
+- `.RequirePermission()` with permission constants
+- `.AllowAnonymous()` for public endpoints
+- Default is authorized
+
+**CrudEndpoints helper:**
+- Standard CRUD responses with consistent status codes
+- For non-standard responses, write custom handlers
+
+**Escape hatch:**
+- Override `ConfigureEndpoints` on the module class for non-standard routes
+
+### 7. Frontend
+
+**Page registry:**
+- Every `IViewEndpoint` must have a matching entry in `Pages/index.ts`
+- Key must match the component name in `Inertia.Render()`
+- Missing entries cause silent client-side 404s
+- Run `npm run validate-pages` to verify
+
+**Build system:**
+- Each module has a `vite.config.ts` using `defineModuleConfig()`
+- Output: `{ModuleName}.pages.js` in `wwwroot/`
+- Library mode — React, React-DOM, @inertiajs/react externalized
+- Dynamic imports within module builds follow Rollup's default behavior (code splitting supported)
+- Dynamic imports between modules are how the system works — ClientApp lazy-loads each module's `pages.js` bundle
+
+**TypeScript types:**
+- `[Dto]` types auto-generate TypeScript interfaces
+- Don't hand-write types that mirror DTOs
+
+**Package structure:**
+- React and Inertia as `peerDependencies`
+- Use `@simplemodule/ui` for shared components
+- Use `@simplemodule/theme-default` for styling
+
+**Linting and formatting:**
+- Biome configured at repo root
+- Single quotes, semicolons, 2-space indent, trailing commas, 100-char width
+- `npm run check` to verify, `npm run check:fix` to auto-fix
+
+### 8. Permissions & Authorization
+
+**Defining permissions:**
+- Sealed class implementing `IModulePermissions`
+- `public const string` fields only (SM0027)
+- Values follow `{ModuleName}.{Action}` (SM0031, SM0034)
+- Class must be sealed (SM0032)
+- No duplicate values (SM0033)
+
+**Registering:**
+- Override `ConfigurePermissions`, call `builder.AddPermissions()`
+
+**Applying:**
+- `.RequirePermission()` on endpoints
+- `.AllowAnonymous()` for public endpoints
+- Never hardcode permission strings
+
+**Rules:**
+- Permissions owned by the defining module
+- Other modules may reference permission constants from Contracts
+- Not every module needs permissions
+
+### 9. Settings & Configuration
+
+**Defining settings:**
+- Override `ConfigureSettings` on the module class
+
+**Scopes:**
+- `System` — global settings that affect infrastructure (database, caching, external service config)
+- `Application` — application-wide preferences visible to all users (site title, default language)
+- `User` — per-user preferences (theme, notification settings)
+
+**Types:** Text, Number, Bool, Json
+
+**Rules:**
+- Namespace setting keys to the module
+- Only for values configurable at runtime
+- For per-environment values, use `IConfiguration` and `appsettings.json`
+- Access other modules' settings through `ISettingsContracts`
+
+### 10. Testing
+
+**Stack:** xUnit.v3, FluentAssertions, Bogus, `SimpleModuleWebApplicationFactory`
+
+**Structure:**
+- Test project per module at `modules/{Name}/tests/{Name}.Tests/`
+- Underscore method naming: `Method_Scenario_Expected`
+
+**Integration tests:**
+- `SimpleModuleWebApplicationFactory` with in-memory SQLite and test auth
+- `CreateAuthenticatedClient(params Claim[] claims)` for auth
+
+**Fake data:**
+- `FakeDataGenerators` for pre-built Bogus fakers
+
+**Database:**
+- SQLite in-memory locally, PostgreSQL in CI
+- Both providers tested in CI
+
+**Rules:**
+- Test through public API (endpoints and contract interfaces)
+- Don't mock the database
+- Test cross-module interactions through contracts
+- Every module should have tests
+
+### 11. Compiler-Enforced Rules
+
+Reference table of all SM diagnostics:
+
+**Database & Entities:**
+- SM0001 (Error): No duplicate DbSet property names across modules
+- SM0003 (Error): Only one IdentityDbContext
+- SM0005 (Error): IdentityDbContext must use three type arguments
+- SM0006 (Warning): Orphaned entity configuration
+- SM0007 (Error): No duplicate entity configurations
+
+**Module Structure:**
+- SM0002 (Warning): Module name must not be empty
+- SM0040 (Error): No duplicate module names
+- SM0043 (Warning): Module must override at least one IModule method
+
+**Dependencies:**
+- SM0010 (Error): No circular module dependencies
+- SM0011 (Error): No direct module-to-module implementation references
+
+**Contracts:**
+- SM0012 (Warning): Contract interface 15+ methods
+- SM0013 (Error): Contract interface 20+ methods
+- SM0014 (Error): Contracts assembly has no public interfaces
+- SM0025 (Error): No implementation for contract interface
+- SM0026 (Error): Multiple implementations of contract interface
+- SM0028 (Error): Implementation must be public
+- SM0029 (Error): Implementation must not be abstract
+- SM0035 (Warning): DTO with no public properties
+- SM0038 (Warning): Infrastructure type in Contracts
+
+**Permissions:**
+- SM0027 (Error): Permission field must be const string
+- SM0031 (Warning): Permission value naming pattern
+- SM0032 (Error): Permission class must be sealed
+- SM0033 (Error): No duplicate permission values
+- SM0034 (Warning): Permission prefix must match module
+
+**View Endpoints:**
+- SM0015 (Error): No duplicate view page names
+- SM0041 (Warning): View page name prefix
+- SM0042 (Error): ViewPrefix required with view endpoints
+
+**Interceptors:**
+- SM0039 (Warning): Interceptor has transitive DbContext dependency
+
+### 12. Framework Contributor Guidelines
+
+**Source generator:**
+- Target netstandard2.0 with `IIncrementalGenerator`
+- Use incremental pipeline for caching
+- New diagnostics use the next available SM number after the highest existing one
+- All diagnostics must have tests
+
+**Adding a diagnostic:**
+- Define descriptor in `DiagnosticEmitter.cs`
+- Add detection logic in discovery or emission
+- Add positive and negative test cases
+- Document in Constitution Section 11
+- Errors for runtime breakage, warnings for code smells
+
+**HostDbContext generation:**
+- Generator merges all module DbSets into one context
+- Entity-to-module mapping drives schema isolation
+- Test against all modules before merging changes
+
+**Database framework:**
+- `ModuleDbContextOptionsBuilder` handles provider detection and routing
+- Interceptors resolved lazily to avoid circular DI
+- `ApplyModuleSchema` must handle PostgreSQL, SQL Server, and SQLite
+
+**Core framework:**
+- All IModule methods must have default implementations
+- New lifecycle hooks require a default no-op
+- Keep Contracts dependencies minimal
+
+**Build and CI:**
+- `TreatWarningsAsErrors` enabled globally
+- `AnalysisLevel=latest-all`, `AnalysisMode=All`
+- Suppressed rules live in `.editorconfig`
+- Tests run against SQLite and PostgreSQL in CI
+
+## CLAUDE.md Changes
+
+After writing the Constitution, CLAUDE.md will be updated to:
+
+1. Remove these sections (moved to Constitution):
+ - Module Communication
+ - Event Handler Patterns & Exception Isolation
+ - EF Core Interceptor DI Patterns
+ - Minimal API Parameter Binding
+ - Database (detailed rules)
+ - Unified HostDbContext (reframed from "Known Limitation" to "deliberate choice" in Constitution)
+
+2. Replace with a brief reference:
+ - Link to `docs/CONSTITUTION.md` for architectural rules
+ - Keep: build commands, test commands, frontend commands, CLI usage
+ - Keep: Adding a New Module checklist (procedural, not architectural)
+ - Keep: C# conventions (enforced by .editorconfig)
+ - Keep: Key Constraints (generator netstandard2.0, TreatWarningsAsErrors)
+ - Keep: Test Infrastructure (factory usage)
+ - Keep: Frontend Packages
+ - Keep: Linting & Formatting
+ - Keep: Task Management and Core Principles (AI workflow)
+
+## Out of Scope
+
+- Code examples in the Constitution (reference codebase instead)
+- Tutorial/walkthrough content (this is a rules document)
+- Future microservice patterns (small team, single deploy)
diff --git a/framework/SimpleModule.Core/Authorization/PermissionAuthorizationHandler.cs b/framework/SimpleModule.Core/Authorization/PermissionAuthorizationHandler.cs
index 6d952791..bb2bc401 100644
--- a/framework/SimpleModule.Core/Authorization/PermissionAuthorizationHandler.cs
+++ b/framework/SimpleModule.Core/Authorization/PermissionAuthorizationHandler.cs
@@ -17,9 +17,22 @@ PermissionRequirement requirement
return Task.CompletedTask;
}
+ // Exact match (fast path)
if (context.User.HasClaim("permission", requirement.Permission))
{
context.Succeed(requirement);
+ return Task.CompletedTask;
+ }
+
+ // Wildcard match: check all permission claims for wildcard patterns
+ foreach (var claim in context.User.Claims)
+ {
+ if (claim.Type == "permission"
+ && PermissionMatcher.IsMatch(claim.Value, requirement.Permission))
+ {
+ context.Succeed(requirement);
+ return Task.CompletedTask;
+ }
}
return Task.CompletedTask;
diff --git a/framework/SimpleModule.Core/Authorization/PermissionMatcher.cs b/framework/SimpleModule.Core/Authorization/PermissionMatcher.cs
new file mode 100644
index 00000000..7574e82e
--- /dev/null
+++ b/framework/SimpleModule.Core/Authorization/PermissionMatcher.cs
@@ -0,0 +1,41 @@
+namespace SimpleModule.Core.Authorization;
+
+///
+/// Matches permission claims against requirements, supporting wildcard patterns.
+///
+///
+/// Wildcard rules:
+///
+/// "Products.*" matches "Products.View", "Products.Create", etc.
+/// "*" matches any single-segment permission
+/// Exact matches are checked first for performance
+/// Only trailing wildcards are supported (no "*.View" or "Pro*.Create")
+///
+///
+public static class PermissionMatcher
+{
+ ///
+ /// Returns true if the satisfies the .
+ ///
+ public static bool IsMatch(string claim, string requirement)
+ {
+ // Exact match (fast path)
+ if (string.Equals(claim, requirement, StringComparison.Ordinal))
+ return true;
+
+ // Wildcard: claim ends with ".*"
+ if (claim.Length > 2
+ && claim[^1] == '*'
+ && claim[^2] == '.'
+ && requirement.StartsWith(claim[..^1], StringComparison.Ordinal))
+ {
+ return true;
+ }
+
+ // Global wildcard: claim is just "*"
+ if (claim is "*")
+ return true;
+
+ return false;
+ }
+}
diff --git a/framework/SimpleModule.Core/Events/BackgroundEventChannel.cs b/framework/SimpleModule.Core/Events/BackgroundEventChannel.cs
new file mode 100644
index 00000000..b5df4866
--- /dev/null
+++ b/framework/SimpleModule.Core/Events/BackgroundEventChannel.cs
@@ -0,0 +1,41 @@
+using System.Threading.Channels;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace SimpleModule.Core.Events;
+
+///
+/// Unbounded channel for queuing events to be dispatched by a background service.
+/// Register as a singleton. The reads from this channel.
+///
+public sealed partial class BackgroundEventChannel(ILogger logger)
+{
+ private readonly Channel> _channel =
+ Channel.CreateUnbounded>(
+ new UnboundedChannelOptions { SingleReader = true }
+ );
+
+ internal ChannelReader> Reader =>
+ _channel.Reader;
+
+ internal void Enqueue(T @event)
+ where T : IEvent
+ {
+ Func dispatch = (sp, ct) =>
+ {
+ var bus = sp.GetRequiredService();
+ return bus.PublishAsync(@event, ct);
+ };
+
+ if (!_channel.Writer.TryWrite(dispatch))
+ {
+ LogEventDropped(logger, typeof(T).Name);
+ }
+ }
+
+ [LoggerMessage(
+ Level = LogLevel.Warning,
+ Message = "Background event '{EventName}' dropped — channel closed"
+ )]
+ private static partial void LogEventDropped(ILogger logger, string eventName);
+}
diff --git a/framework/SimpleModule.Core/Events/BackgroundEventDispatcher.cs b/framework/SimpleModule.Core/Events/BackgroundEventDispatcher.cs
new file mode 100644
index 00000000..3695b9bf
--- /dev/null
+++ b/framework/SimpleModule.Core/Events/BackgroundEventDispatcher.cs
@@ -0,0 +1,49 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace SimpleModule.Core.Events;
+
+///
+/// Background service that drains the and dispatches
+/// events to their handlers in a scoped DI context. Events are dispatched concurrently
+/// to avoid head-of-line blocking from slow handlers.
+///
+public sealed partial class BackgroundEventDispatcher(
+ BackgroundEventChannel channel,
+ IServiceScopeFactory scopeFactory,
+ ILogger logger
+) : BackgroundService
+{
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ await foreach (var dispatch in channel.Reader.ReadAllAsync(stoppingToken))
+ {
+ _ = DispatchAsync(dispatch, stoppingToken);
+ }
+ }
+
+ private async Task DispatchAsync(
+ Func dispatch,
+ CancellationToken stoppingToken
+ )
+ {
+ try
+ {
+ using var scope = scopeFactory.CreateScope();
+ await dispatch(scope.ServiceProvider, stoppingToken);
+ }
+#pragma warning disable CA1031 // Background dispatcher must not crash on handler failures
+ catch (Exception ex)
+#pragma warning restore CA1031
+ {
+ LogDispatchFailed(logger, ex);
+ }
+ }
+
+ [LoggerMessage(
+ Level = LogLevel.Error,
+ Message = "Background event dispatch failed"
+ )]
+ private static partial void LogDispatchFailed(ILogger logger, Exception exception);
+}
diff --git a/framework/SimpleModule.Core/Events/EventBus.cs b/framework/SimpleModule.Core/Events/EventBus.cs
index 000944de..1a6991e6 100644
--- a/framework/SimpleModule.Core/Events/EventBus.cs
+++ b/framework/SimpleModule.Core/Events/EventBus.cs
@@ -72,8 +72,11 @@ namespace SimpleModule.Core.Events;
/// to know which handlers failed, inspect the collection.
///
///
-public sealed partial class EventBus(IServiceProvider serviceProvider, ILogger logger)
- : IEventBus
+public sealed partial class EventBus(
+ IServiceProvider serviceProvider,
+ ILogger logger,
+ BackgroundEventChannel backgroundChannel
+) : IEventBus
{
///
/// Publishes an event to all registered handlers, ensuring all handlers execute even if some fail.
@@ -138,6 +141,13 @@ public async Task PublishAsync(T @event, CancellationToken cancellationToken
}
}
+ ///
+ public void PublishInBackground(T @event)
+ where T : IEvent
+ {
+ backgroundChannel.Enqueue(@event);
+ }
+
[LoggerMessage(
Level = LogLevel.Error,
Message = "Event handler {HandlerName} failed for event {EventName}"
diff --git a/framework/SimpleModule.Core/Events/IEventBus.cs b/framework/SimpleModule.Core/Events/IEventBus.cs
index 711ddfe8..805eb336 100644
--- a/framework/SimpleModule.Core/Events/IEventBus.cs
+++ b/framework/SimpleModule.Core/Events/IEventBus.cs
@@ -30,4 +30,17 @@ public interface IEventBus
///
Task PublishAsync(T @event, CancellationToken cancellationToken = default)
where T : IEvent;
+
+ ///
+ /// Enqueues an event for background dispatch, returning immediately without waiting for handlers.
+ ///
+ /// The event type, must implement .
+ /// The event to publish in the background.
+ ///
+ /// The event is dispatched asynchronously by a background service. Handler exceptions are
+ /// logged but do not propagate to the caller. Use this for fire-and-forget events where
+ /// the caller does not need to know if handlers succeeded (e.g., audit logging, notifications).
+ ///
+ void PublishInBackground(T @event)
+ where T : IEvent;
}
diff --git a/framework/SimpleModule.Core/Hosting/IModuleDbContext.cs b/framework/SimpleModule.Core/Hosting/IModuleDbContext.cs
new file mode 100644
index 00000000..f14fa5ae
--- /dev/null
+++ b/framework/SimpleModule.Core/Hosting/IModuleDbContext.cs
@@ -0,0 +1,27 @@
+namespace SimpleModule.Core.Hosting;
+
+///
+/// Marker interface for module-owned DbContexts that participate in the unified HostDbContext.
+/// Implementing this interface signals that a module's DbContext contributes entities to the
+/// shared database and requires schema isolation.
+///
+///
+///
+/// Currently, all module entities are merged into a single HostDbContext by the source generator.
+/// This means all modules share one database connection and one migration history. Schema
+/// isolation is provider-dependent (PostgreSQL/SQL Server use schemas, SQLite uses table prefixes).
+///
+///
+/// Future direction: modules implementing this interface may opt into independent DbContext
+/// resolution, enabling per-module databases, independent migrations, and a migration path
+/// toward microservices. The generator will use this interface to determine which modules
+/// participate in the unified context vs. manage their own.
+///
+///
+public interface IModuleDbContext
+{
+ ///
+ /// The schema name (or table prefix for SQLite) used to isolate this module's entities.
+ ///
+ static abstract string SchemaName { get; }
+}
diff --git a/framework/SimpleModule.Core/Hosting/ModuleLifecycleHostedService.cs b/framework/SimpleModule.Core/Hosting/ModuleLifecycleHostedService.cs
new file mode 100644
index 00000000..61e60136
--- /dev/null
+++ b/framework/SimpleModule.Core/Hosting/ModuleLifecycleHostedService.cs
@@ -0,0 +1,79 @@
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace SimpleModule.Core.Hosting;
+
+///
+/// Calls and lifecycle hooks
+/// on all discovered modules during application startup and shutdown.
+///
+public sealed partial class ModuleLifecycleHostedService(
+ IEnumerable modules,
+ ILogger logger
+) : IHostedLifecycleService
+{
+ public Task StartingAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+
+ public async Task StartAsync(CancellationToken cancellationToken)
+ {
+ foreach (var module in modules)
+ {
+ var moduleName = module.GetType().Name;
+ try
+ {
+ await module.OnStartAsync(cancellationToken);
+ LogModuleStarted(logger, moduleName);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ LogModuleStartFailed(logger, moduleName, ex);
+ throw;
+ }
+ }
+ }
+
+ public Task StartedAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+
+ public Task StoppingAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+
+ [SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Module stop failures must not prevent other modules from stopping")]
+ public async Task StopAsync(CancellationToken cancellationToken)
+ {
+ foreach (var module in modules)
+ {
+ var moduleName = module.GetType().Name;
+ try
+ {
+ await module.OnStopAsync(cancellationToken);
+ LogModuleStopped(logger, moduleName);
+ }
+ catch (OperationCanceledException)
+ {
+ // Shutdown requested — continue stopping remaining modules
+ }
+ catch (Exception ex)
+ {
+ LogModuleStopFailed(logger, moduleName, ex);
+ }
+ }
+ }
+
+ public Task StoppedAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+
+ [LoggerMessage(Level = LogLevel.Debug, Message = "Module {ModuleName} started")]
+ private static partial void LogModuleStarted(ILogger logger, string moduleName);
+
+ [LoggerMessage(Level = LogLevel.Error, Message = "Module {ModuleName} failed during OnStartAsync")]
+ private static partial void LogModuleStartFailed(ILogger logger, string moduleName, Exception exception);
+
+ [LoggerMessage(Level = LogLevel.Debug, Message = "Module {ModuleName} stopped")]
+ private static partial void LogModuleStopped(ILogger logger, string moduleName);
+
+ [LoggerMessage(Level = LogLevel.Error, Message = "Module {ModuleName} failed during OnStopAsync")]
+ private static partial void LogModuleStopFailed(ILogger logger, string moduleName, Exception exception);
+}
diff --git a/framework/SimpleModule.Core/IModule.cs b/framework/SimpleModule.Core/IModule.cs
index 3f575018..00e8e3a1 100644
--- a/framework/SimpleModule.Core/IModule.cs
+++ b/framework/SimpleModule.Core/IModule.cs
@@ -16,4 +16,23 @@ virtual void ConfigureMiddleware(IApplicationBuilder app) { }
virtual void ConfigureMenu(IMenuBuilder menus) { }
virtual void ConfigurePermissions(PermissionRegistryBuilder builder) { }
virtual void ConfigureSettings(ISettingsBuilder settings) { }
+
+ ///
+ /// Called once during application startup after all services are registered.
+ /// Use for one-time initialization such as loading certificates, warming caches, or verifying external dependencies.
+ ///
+ virtual Task OnStartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+
+ ///
+ /// Called during graceful application shutdown.
+ /// Use for cleanup such as flushing buffers, closing connections, or draining background work.
+ ///
+ virtual Task OnStopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+
+ ///
+ /// Returns the current health status of this module.
+ /// Called by the module health check endpoint to report per-module health.
+ ///
+ virtual Task CheckHealthAsync(CancellationToken cancellationToken) =>
+ Task.FromResult(ModuleHealthStatus.Healthy);
}
diff --git a/framework/SimpleModule.Core/ModuleHealthStatus.cs b/framework/SimpleModule.Core/ModuleHealthStatus.cs
new file mode 100644
index 00000000..e0b69153
--- /dev/null
+++ b/framework/SimpleModule.Core/ModuleHealthStatus.cs
@@ -0,0 +1,11 @@
+namespace SimpleModule.Core;
+
+///
+/// Represents the health status of a module.
+///
+public enum ModuleHealthStatus
+{
+ Healthy,
+ Degraded,
+ Unhealthy,
+}
diff --git a/framework/SimpleModule.Core/Result.cs b/framework/SimpleModule.Core/Result.cs
new file mode 100644
index 00000000..78968eca
--- /dev/null
+++ b/framework/SimpleModule.Core/Result.cs
@@ -0,0 +1,92 @@
+using System.Diagnostics.CodeAnalysis;
+
+namespace SimpleModule.Core;
+
+///
+/// Represents the outcome of an operation that may fail with a structured error.
+///
+/// The type of the success value.
+[SuppressMessage("Design", "CA1000:Do not declare static members on generic types", Justification = "Standard Result pattern requires static factory methods")]
+public readonly struct Result : IEquatable>
+{
+ private readonly T? _value;
+ private readonly ResultError? _error;
+
+ private Result(T value)
+ {
+ _value = value;
+ _error = null;
+ IsSuccess = true;
+ }
+
+ private Result(ResultError error)
+ {
+ _value = default;
+ _error = error;
+ IsSuccess = false;
+ }
+
+ public bool IsSuccess { get; }
+
+ public bool IsFailure => !IsSuccess;
+
+ public T Value => IsSuccess
+ ? _value!
+ : throw new InvalidOperationException("Cannot access Value on a failed Result.");
+
+ public ResultError Error => !IsSuccess
+ ? _error!
+ : throw new InvalidOperationException("Cannot access Error on a successful Result.");
+
+ public static Result Ok(T value) => new(value);
+
+ public static Result Fail(string message) => new(new ResultError(message));
+
+ public static Result Fail(string message, Dictionary validationErrors) =>
+ new(new ResultError(message, validationErrors));
+
+ ///
+ /// Creates a from a value (alternate for implicit operator).
+ ///
+ [SuppressMessage("Design", "CA2225:Operator overloads have named alternates", Justification = "ToResult provided")]
+ public static implicit operator Result(T value) => Ok(value);
+
+ ///
+ /// Named alternate for the implicit conversion operator.
+ ///
+ public static Result FromValue(T value) => Ok(value);
+
+ ///
+ /// Maps the success value to a new type using the provided function.
+ ///
+ public Result Map(Func map) =>
+ IsSuccess
+ ? Result.Ok(map(_value!))
+ : Result.Fail(Error.Message, Error.ValidationErrors ?? new Dictionary());
+
+ ///
+ /// Returns the value if successful, or the provided fallback value.
+ ///
+ public T ValueOr(T fallback) => IsSuccess ? _value! : fallback;
+
+ public bool Equals(Result other) =>
+ IsSuccess == other.IsSuccess
+ && EqualityComparer.Default.Equals(_value, other._value)
+ && Equals(_error, other._error);
+
+ public override bool Equals(object? obj) => obj is Result other && Equals(other);
+
+ public override int GetHashCode() => HashCode.Combine(IsSuccess, _value, _error);
+
+ public static bool operator ==(Result left, Result right) => left.Equals(right);
+
+ public static bool operator !=(Result left, Result right) => !left.Equals(right);
+}
+
+///
+/// Describes why an operation failed.
+///
+public sealed record ResultError(
+ string Message,
+ Dictionary? ValidationErrors = null
+);
diff --git a/framework/SimpleModule.Core/ViewPageAttribute.cs b/framework/SimpleModule.Core/ViewPageAttribute.cs
new file mode 100644
index 00000000..8f565348
--- /dev/null
+++ b/framework/SimpleModule.Core/ViewPageAttribute.cs
@@ -0,0 +1,15 @@
+namespace SimpleModule.Core;
+
+///
+/// Declares the Inertia component name for an .
+/// The generator uses this to validate page registry entries and enforce naming conventions.
+///
+///
+/// The Inertia component name (e.g., "Products/Browse"). Must match the key in Pages/index.ts
+/// and the first argument to Inertia.Render().
+///
+[AttributeUsage(AttributeTargets.Class, Inherited = false)]
+public sealed class ViewPageAttribute(string component) : Attribute
+{
+ public string Component { get; } = component;
+}
diff --git a/framework/SimpleModule.Generator/AnalyzerReleases.Unshipped.md b/framework/SimpleModule.Generator/AnalyzerReleases.Unshipped.md
index 3064768b..c5223b99 100644
--- a/framework/SimpleModule.Generator/AnalyzerReleases.Unshipped.md
+++ b/framework/SimpleModule.Generator/AnalyzerReleases.Unshipped.md
@@ -25,3 +25,8 @@ SM0034 | SimpleModule.Generator | Warning | Permission value prefix does not mat
SM0035 | SimpleModule.Generator | Warning | DTO type in contracts has no public properties
SM0038 | SimpleModule.Generator | Warning | Infrastructure type in Contracts assembly
SM0039 | SimpleModule.Generator | Warning | SaveChanges interceptor has transitive DbContext dependency
+SM0015 | SimpleModule.Generator | Error | Duplicate view page name across modules
+SM0040 | SimpleModule.Generator | Error | Duplicate module name
+SM0041 | SimpleModule.Generator | Warning | View page name does not match module name prefix
+SM0042 | SimpleModule.Generator | Error | Module has view endpoints but no ViewPrefix
+SM0043 | SimpleModule.Generator | Warning | Module does not override any IModule methods
diff --git a/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs b/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs
index 5c1016d2..b6fba426 100644
--- a/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs
+++ b/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs
@@ -349,7 +349,8 @@ internal sealed class EndpointInfo
internal sealed class ViewInfo
{
public string FullyQualifiedName { get; set; } = "";
- public string Page { get; set; } = "";
+ public string? Page { get; set; }
+ public string InferredClassName { get; set; } = "";
}
internal sealed class DtoTypeInfo
diff --git a/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs
index fddde914..8addf8db 100644
--- a/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs
+++ b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs
@@ -60,22 +60,55 @@ is not IAssemblySymbol assemblySymbol
// Discover IEndpoint implementors per module assembly.
// Classification is by interface type: IViewEndpoint -> view, IEndpoint -> API.
+ // Scan each assembly once, then match endpoints to the closest module by namespace.
if (endpointInterfaceSymbol is not null)
{
+ var endpointScannedAssemblies = new HashSet(
+ SymbolEqualityComparer.Default
+ );
foreach (var module in modules)
{
if (!moduleSymbols.TryGetValue(module.FullyQualifiedName, out var typeSymbol))
continue;
var assembly = typeSymbol.ContainingAssembly;
+ if (!endpointScannedAssemblies.Add(assembly))
+ continue;
+
+ var rawEndpoints = new List();
+ var rawViews = new List();
FindEndpointTypes(
assembly.GlobalNamespace,
endpointInterfaceSymbol,
viewEndpointInterfaceSymbol,
- module.ModuleName,
- module.Endpoints,
- module.Views
+ rawEndpoints,
+ rawViews
);
+
+ // Match each endpoint/view to the module whose namespace is closest
+ foreach (var ep in rawEndpoints)
+ {
+ var epFqn = TypeMappingHelpers.StripGlobalPrefix(ep.FullyQualifiedName);
+ var ownerName = FindClosestModuleName(epFqn, modules);
+ var owner = modules.Find(m => m.ModuleName == ownerName);
+ if (owner is not null)
+ owner.Endpoints.Add(ep);
+ }
+
+ foreach (var v in rawViews)
+ {
+ var vFqn = TypeMappingHelpers.StripGlobalPrefix(v.FullyQualifiedName);
+ var ownerName = FindClosestModuleName(vFqn, modules);
+ var owner = modules.Find(m => m.ModuleName == ownerName);
+ if (owner is not null)
+ {
+ // Set page name using owner module name if not already set via [ViewPage]
+ if (v.Page is null)
+ v.Page = ownerName + "/" + v.InferredClassName;
+
+ owner.Views.Add(v);
+ }
+ }
}
}
@@ -276,7 +309,7 @@ is not IAssemblySymbol assemblySymbol
);
}
- // Step 3c: Find IModulePermissions implementors in module assemblies
+ // Step 3c: Find IModulePermissions implementors in module and contracts assemblies
var permissionClasses = new List();
var modulePermissionsSymbol = compilation.GetTypeByMetadataName(
"SimpleModule.Core.Authorization.IModulePermissions"
@@ -296,6 +329,20 @@ is not IAssemblySymbol assemblySymbol
permissionClasses
);
}
+
+ // Also scan contracts assemblies for permission classes
+ foreach (var kvp in contractsAssemblySymbols)
+ {
+ if (contractsAssemblyMap.TryGetValue(kvp.Key, out var moduleName))
+ {
+ FindPermissionClasses(
+ kvp.Value.GlobalNamespace,
+ modulePermissionsSymbol,
+ moduleName,
+ permissionClasses
+ );
+ }
+ }
}
// Step 3d: Find ISaveChangesInterceptor implementors in module assemblies
@@ -424,7 +471,7 @@ is not IAssemblySymbol assemblySymbol
e.AllowAnonymous
))
.ToImmutableArray(),
- m.Views.Select(v => new ViewInfoRecord(v.FullyQualifiedName, v.Page))
+ m.Views.Select(v => new ViewInfoRecord(v.FullyQualifiedName, v.Page ?? ""))
.ToImmutableArray()
))
.ToImmutableArray(),
@@ -584,7 +631,6 @@ private static void FindEndpointTypes(
INamespaceSymbol namespaceSymbol,
INamedTypeSymbol endpointInterfaceSymbol,
INamedTypeSymbol? viewEndpointInterfaceSymbol,
- string moduleName,
List endpoints,
List views
)
@@ -597,7 +643,6 @@ List views
childNamespace,
endpointInterfaceSymbol,
viewEndpointInterfaceSymbol,
- moduleName,
endpoints,
views
);
@@ -613,6 +658,26 @@ viewEndpointInterfaceSymbol is not null
&& ImplementsInterface(typeSymbol, viewEndpointInterfaceSymbol)
)
{
+ // Prefer [ViewPage("Component")] attribute over class name inference
+ string? page = null;
+ foreach (var attr in typeSymbol.GetAttributes())
+ {
+ var attrName = attr.AttributeClass?.ToDisplayString(
+ SymbolDisplayFormat.FullyQualifiedFormat
+ );
+ if (
+ attrName
+ == "global::SimpleModule.Core.ViewPageAttribute"
+ && attr.ConstructorArguments.Length > 0
+ && attr.ConstructorArguments[0].Value is string component
+ )
+ {
+ page = component;
+ break;
+ }
+ }
+
+ // Infer class name for deferred page name computation
var className = typeSymbol.Name;
if (className.EndsWith("Endpoint", StringComparison.Ordinal))
className = className.Substring(
@@ -620,13 +685,17 @@ viewEndpointInterfaceSymbol is not null
className.Length - "Endpoint".Length
);
else if (className.EndsWith("View", StringComparison.Ordinal))
- className = className.Substring(0, className.Length - "View".Length);
+ className = className.Substring(
+ 0,
+ className.Length - "View".Length
+ );
views.Add(
new ViewInfo
{
FullyQualifiedName = fqn,
- Page = moduleName + "/" + className,
+ Page = page,
+ InferredClassName = className,
}
);
}
diff --git a/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs b/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs
index 4a10e46c..a81ba663 100644
--- a/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs
+++ b/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs
@@ -1,5 +1,7 @@
+using System;
using System.Collections.Generic;
using System.Collections.Immutable;
+using System.Linq;
using Microsoft.CodeAnalysis;
namespace SimpleModule.Generator;
@@ -81,7 +83,7 @@ internal sealed class DiagnosticEmitter : IEmitter
internal static readonly DiagnosticDescriptor ContractInterfaceTooLargeWarning = new(
id: "SM0012",
title: "Contract interface has too many methods",
- messageFormat: "Contract interface '{0}' has {1} methods, which exceeds the recommended maximum of 15. Large contract interfaces force consuming modules to depend on methods they don't use. Consider splitting into focused interfaces (e.g., I{2}Queries, I{2}Commands). Your module class can implement all of them. Thresholds are configurable in .editorconfig: simplemodule.max_contract_methods_warn = 15, simplemodule.max_contract_methods_error = 20. Learn more: https://docs.simplemodule.dev/contract-design.",
+ messageFormat: "Contract interface '{0}' has {1} methods, which exceeds the recommended maximum of 15. Large contract interfaces force consuming modules to depend on methods they don't use. Consider splitting into focused interfaces (e.g., I{2}Queries, I{2}Commands). Your module class can implement all of them. Warning threshold: 15 methods, error threshold: 20 methods. Learn more: https://docs.simplemodule.dev/contract-design.",
category: "SimpleModule.Generator",
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true
@@ -90,7 +92,7 @@ internal sealed class DiagnosticEmitter : IEmitter
internal static readonly DiagnosticDescriptor ContractInterfaceTooLargeError = new(
id: "SM0013",
title: "Contract interface must be split",
- messageFormat: "Contract interface '{0}' has {1} methods and must be split before the project will compile. Interfaces with more than 20 methods are not allowed. Split into focused interfaces (e.g., I{2}Queries, I{2}Commands). Your module class can implement all of them. Thresholds are configurable in .editorconfig: simplemodule.max_contract_methods_warn = 15, simplemodule.max_contract_methods_error = 20. Learn more: https://docs.simplemodule.dev/contract-design.",
+ messageFormat: "Contract interface '{0}' has {1} methods and must be split before the project will compile. Interfaces with more than 20 methods are not allowed. Split into focused interfaces (e.g., I{2}Queries, I{2}Commands). Your module class can implement all of them. Warning threshold: 15 methods, error threshold: 20 methods. Learn more: https://docs.simplemodule.dev/contract-design.",
category: "SimpleModule.Generator",
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true
@@ -204,6 +206,15 @@ internal sealed class DiagnosticEmitter : IEmitter
isEnabledByDefault: true
);
+ internal static readonly DiagnosticDescriptor DuplicateViewPageName = new(
+ id: "SM0015",
+ title: "Duplicate view page name across modules",
+ messageFormat: "View page name '{0}' is registered by multiple endpoints: '{1}' (module {2}) and '{3}' (module {4}). Each IViewEndpoint must map to a unique page name. Rename one of the endpoint classes or move it to a different module.",
+ category: "SimpleModule.Generator",
+ defaultSeverity: DiagnosticSeverity.Error,
+ isEnabledByDefault: true
+ );
+
internal static readonly DiagnosticDescriptor InterceptorDependsOnDbContext = new(
id: "SM0039",
title: "SaveChanges interceptor has transitive DbContext dependency",
@@ -213,6 +224,42 @@ internal sealed class DiagnosticEmitter : IEmitter
isEnabledByDefault: true
);
+ internal static readonly DiagnosticDescriptor DuplicateModuleName = new(
+ id: "SM0040",
+ title: "Duplicate module name",
+ messageFormat: "Module name '{0}' is used by both '{1}' and '{2}'. Each module must have a unique name. Duplicate names cause route prefix conflicts, database schema collisions, and ambiguous TypeScript module grouping.",
+ category: "SimpleModule.Generator",
+ defaultSeverity: DiagnosticSeverity.Error,
+ isEnabledByDefault: true
+ );
+
+ internal static readonly DiagnosticDescriptor ViewPagePrefixMismatch = new(
+ id: "SM0041",
+ title: "View page name does not match module name prefix",
+ messageFormat: "View endpoint '{0}' in module '{1}' maps to page '{2}', but page names should start with the module name prefix '{1}/'. This causes the React page resolver to look for the page bundle in the wrong module. Rename the endpoint class or move it to the correct module.",
+ category: "SimpleModule.Generator",
+ defaultSeverity: DiagnosticSeverity.Warning,
+ isEnabledByDefault: true
+ );
+
+ internal static readonly DiagnosticDescriptor ViewEndpointWithoutViewPrefix = new(
+ id: "SM0042",
+ title: "Module has view endpoints but no ViewPrefix",
+ messageFormat: "Module '{0}' contains {1} IViewEndpoint implementation(s) but does not define a ViewPrefix. View endpoints will not be routed correctly. Add ViewPrefix to the [Module] attribute: [Module(\"{0}\", ViewPrefix = \"/{2}\")].",
+ category: "SimpleModule.Generator",
+ defaultSeverity: DiagnosticSeverity.Error,
+ isEnabledByDefault: true
+ );
+
+ internal static readonly DiagnosticDescriptor EmptyModuleWarning = new(
+ id: "SM0043",
+ title: "Module does not override any IModule methods",
+ messageFormat: "Module '{0}' implements IModule but does not override any configuration methods (ConfigureServices, ConfigureMenu, etc.). This module will be discovered but has no effect. If this is intentional, add at least ConfigureServices with a comment explaining why.",
+ category: "SimpleModule.Generator",
+ defaultSeverity: DiagnosticSeverity.Warning,
+ isEnabledByDefault: true
+ );
+
public void Emit(SourceProductionContext context, DiscoveryData data)
{
// SM0002: Empty module name
@@ -230,6 +277,58 @@ public void Emit(SourceProductionContext context, DiscoveryData data)
}
}
+ // SM0040: Duplicate module name
+ var seenModuleNames = new Dictionary();
+ foreach (var module in data.Modules)
+ {
+ if (string.IsNullOrEmpty(module.ModuleName))
+ continue;
+
+ if (seenModuleNames.TryGetValue(module.ModuleName, out var existingFqn))
+ {
+ context.ReportDiagnostic(
+ Diagnostic.Create(
+ DuplicateModuleName,
+ Location.None,
+ module.ModuleName,
+ Strip(existingFqn),
+ Strip(module.FullyQualifiedName)
+ )
+ );
+ }
+ else
+ {
+ seenModuleNames[module.ModuleName] = module.FullyQualifiedName;
+ }
+ }
+
+ // SM0043: Empty module (no IModule methods overridden)
+ var moduleNamesWithDbContext = new HashSet(
+ data.DbContexts.Select(db => db.ModuleName),
+ StringComparer.Ordinal
+ );
+ foreach (var module in data.Modules)
+ {
+ if (!module.HasConfigureServices
+ && !module.HasConfigureEndpoints
+ && !module.HasConfigureMenu
+ && !module.HasConfigurePermissions
+ && !module.HasConfigureMiddleware
+ && !module.HasConfigureSettings
+ && module.Endpoints.Length == 0
+ && module.Views.Length == 0
+ && !moduleNamesWithDbContext.Contains(module.ModuleName))
+ {
+ context.ReportDiagnostic(
+ Diagnostic.Create(
+ EmptyModuleWarning,
+ Location.None,
+ module.ModuleName
+ )
+ );
+ }
+ }
+
// SM0004: DbContext with no DbSets — silently skipped.
// Some DbContexts (e.g., OpenIddict) manage tables internally without public DbSet
// properties. These are excluded from the unified HostDbContext but are not an error.
@@ -658,6 +757,77 @@ public void Emit(SourceProductionContext context, DiscoveryData data)
}
}
+ // SM0015: Duplicate view page name across modules
+ var seenPages =
+ new Dictionary();
+ foreach (var module in data.Modules)
+ {
+ foreach (var view in module.Views)
+ {
+ if (seenPages.TryGetValue(view.Page, out var existing))
+ {
+ context.ReportDiagnostic(
+ Diagnostic.Create(
+ DuplicateViewPageName,
+ Location.None,
+ view.Page,
+ Strip(existing.EndpointFqn),
+ existing.ModuleName,
+ Strip(view.FullyQualifiedName),
+ module.ModuleName
+ )
+ );
+ }
+ else
+ {
+ seenPages[view.Page] = (view.FullyQualifiedName, module.ModuleName);
+ }
+ }
+ }
+
+ // SM0041: View page prefix must match module name
+ foreach (var module in data.Modules)
+ {
+ if (string.IsNullOrEmpty(module.ModuleName))
+ continue;
+
+ var expectedPrefix = module.ModuleName + "/";
+ foreach (var view in module.Views)
+ {
+ if (!view.Page.StartsWith(expectedPrefix, System.StringComparison.Ordinal))
+ {
+ context.ReportDiagnostic(
+ Diagnostic.Create(
+ ViewPagePrefixMismatch,
+ Location.None,
+ Strip(view.FullyQualifiedName),
+ module.ModuleName,
+ view.Page
+ )
+ );
+ }
+ }
+ }
+
+ // SM0042: Module with views but no ViewPrefix
+ foreach (var module in data.Modules)
+ {
+ if (module.Views.Length > 0 && string.IsNullOrEmpty(module.ViewPrefix))
+ {
+#pragma warning disable CA1308 // Route prefixes are conventionally lowercase
+ context.ReportDiagnostic(
+ Diagnostic.Create(
+ ViewEndpointWithoutViewPrefix,
+ Location.None,
+ module.ModuleName,
+ module.Views.Length,
+ module.ModuleName.ToLowerInvariant()
+ )
+ );
+#pragma warning restore CA1308
+ }
+ }
+
// SM0039: Interceptor depends on contract whose implementation takes a DbContext
foreach (var interceptor in data.Interceptors)
{
diff --git a/framework/SimpleModule.Generator/Emitters/ModuleExtensionsEmitter.cs b/framework/SimpleModule.Generator/Emitters/ModuleExtensionsEmitter.cs
index c8d0a871..40dfe968 100644
--- a/framework/SimpleModule.Generator/Emitters/ModuleExtensionsEmitter.cs
+++ b/framework/SimpleModule.Generator/Emitters/ModuleExtensionsEmitter.cs
@@ -20,6 +20,7 @@ public void Emit(SourceProductionContext context, DiscoveryData data)
sb.AppendLine("using Microsoft.AspNetCore.Http.Json;");
sb.AppendLine("using Microsoft.Extensions.Configuration;");
sb.AppendLine("using Microsoft.Extensions.DependencyInjection;");
+ sb.AppendLine("using Microsoft.Extensions.Hosting;");
sb.AppendLine("using Microsoft.AspNetCore.Authorization;");
sb.AppendLine("using SimpleModule.Core.Authorization;");
sb.AppendLine();
@@ -154,6 +155,19 @@ public void Emit(SourceProductionContext context, DiscoveryData data)
sb.AppendLine(" });");
}
+ sb.AppendLine();
+ sb.AppendLine(" // Register module instances for lifecycle management");
+ foreach (var module in sortedModules)
+ {
+ var fieldName = TypeMappingHelpers.GetModuleFieldName(module.FullyQualifiedName);
+ sb.AppendLine(
+ $" services.AddSingleton({fieldName});"
+ );
+ }
+ sb.AppendLine(
+ " services.AddHostedService();"
+ );
+
sb.AppendLine();
sb.AppendLine(" return services;");
sb.AppendLine(" }");
diff --git a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs
index 3bd506b5..debca8f5 100644
--- a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs
+++ b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs
@@ -63,6 +63,8 @@ public static WebApplicationBuilder AddSimpleModuleInfrastructure(
builder.Services.AddSimpleModuleBlazor();
}
+ builder.Services.AddSingleton();
+ builder.Services.AddHostedService();
builder.Services.AddScoped();
builder.Services.AddScoped();
diff --git a/modules/Admin/src/Admin/types.ts b/modules/Admin/src/Admin/types.ts
deleted file mode 100644
index 140b48c4..00000000
--- a/modules/Admin/src/Admin/types.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-// Auto-generated from [Dto] types — do not edit
-export interface AdminPermissions {
-}
-
-export interface AuditLogEntryDto {
- id: number;
- userId: string;
- performedByUserId: string;
- performedByName: string;
- action: string;
- details: string;
- timestamp: string;
-}
-
diff --git a/modules/Admin/src/SimpleModule.Admin/AdminModule.cs b/modules/Admin/src/SimpleModule.Admin/AdminModule.cs
index cd5eadf7..79cadd4a 100644
--- a/modules/Admin/src/SimpleModule.Admin/AdminModule.cs
+++ b/modules/Admin/src/SimpleModule.Admin/AdminModule.cs
@@ -8,7 +8,7 @@
namespace SimpleModule.Admin;
-[Module(AdminConstants.ModuleName)]
+[Module(AdminConstants.ModuleName, ViewPrefix = "/admin")]
public class AdminModule : IModule
{
public void ConfigureServices(IServiceCollection services, IConfiguration configuration)
diff --git a/modules/Admin/src/SimpleModule.Admin/Endpoints/Admin/AdminRolesEndpoint.cs b/modules/Admin/src/SimpleModule.Admin/Endpoints/Admin/AdminRolesEndpoint.cs
index 53ae403b..d61a8a90 100644
--- a/modules/Admin/src/SimpleModule.Admin/Endpoints/Admin/AdminRolesEndpoint.cs
+++ b/modules/Admin/src/SimpleModule.Admin/Endpoints/Admin/AdminRolesEndpoint.cs
@@ -1,14 +1,12 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
-using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using SimpleModule.Admin.Services;
using SimpleModule.Core;
using SimpleModule.Permissions.Contracts;
-using SimpleModule.Users;
-using SimpleModule.Users.Entities;
+using SimpleModule.Users.Contracts;
namespace SimpleModule.Admin.Endpoints.Admin;
@@ -28,7 +26,7 @@ public void Map(IEndpointRouteBuilder app)
[FromForm] string name,
[FromForm] string? description,
HttpContext context,
- RoleManager roleManager,
+ IRoleAdminContracts roleAdmin,
IPermissionContracts permissionContracts,
AuditService audit
) =>
@@ -37,15 +35,8 @@ AuditService audit
if (string.IsNullOrEmpty(trimmedName))
return TypedResults.Redirect("/admin/roles/create");
- var role = new ApplicationRole
- {
- Name = trimmedName,
- Description = description?.Trim() is { Length: > 0 } d ? d : null,
- };
-
- var result = await roleManager.CreateAsync(role);
- if (!result.Succeeded)
- return TypedResults.Redirect("/admin/roles/create");
+ var trimmedDescription = description?.Trim() is { Length: > 0 } d ? d : null;
+ var role = await roleAdmin.CreateRoleAsync(trimmedName, trimmedDescription);
var form = await context.Request.ReadFormAsync();
var filteredPermissions = form["permissions"]
@@ -61,7 +52,8 @@ await permissionContracts.SetPermissionsForRoleAsync(
);
}
- var adminUserId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "";
+ var adminUserId =
+ context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "";
await audit.LogAsync(
role.Id,
adminUserId,
@@ -83,24 +75,29 @@ async Task (
[FromForm] string name,
[FromForm] string? description,
HttpContext context,
- RoleManager roleManager,
+ IRoleAdminContracts roleAdmin,
AuditService audit
) =>
{
- var role = await roleManager.FindByIdAsync(id);
+ var role = await roleAdmin.GetRoleByIdAsync(id);
if (role is null)
return TypedResults.NotFound();
- role.Name = name.Trim();
- role.Description = description?.Trim() is { Length: > 0 } d ? d : null;
- await roleManager.UpdateAsync(role);
+ var trimmedName = name.Trim();
+ var trimmedDescription = description?.Trim() is { Length: > 0 } d ? d : null;
+ await roleAdmin.UpdateRoleAsync(
+ id,
+ trimmedName,
+ trimmedDescription
+ );
- var adminUserId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "";
+ var adminUserId =
+ context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "";
await audit.LogAsync(
- role.Id,
+ id,
adminUserId,
"RoleUpdated",
- $"Role '{role.Name}' updated"
+ $"Role '{trimmedName}' updated"
);
return TypedResults.Redirect($"/admin/roles/{id}/edit?tab=details");
@@ -115,12 +112,12 @@ await audit.LogAsync(
async Task (
string id,
HttpContext context,
- RoleManager roleManager,
+ IRoleAdminContracts roleAdmin,
IPermissionContracts permissionContracts,
AuditService audit
) =>
{
- var role = await roleManager.FindByIdAsync(id);
+ var role = await roleAdmin.GetRoleByIdAsync(id);
if (role is null)
return TypedResults.NotFound();
@@ -135,13 +132,14 @@ AuditService audit
roleId
);
- var adminUserId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "";
+ var adminUserId =
+ context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "";
// Audit removed permissions
foreach (var perm in currentPermissions.Where(p => !newPermissions.Contains(p)))
{
await audit.LogAsync(
- role.Id,
+ id,
adminUserId,
"RolePermissionRemoved",
$"Permission '{perm}' removed from role '{role.Name}'"
@@ -149,10 +147,12 @@ await audit.LogAsync(
}
// Audit added permissions
- foreach (var perm in newPermissions.Where(p => !currentPermissions.Contains(p)))
+ foreach (
+ var perm in newPermissions.Where(p => !currentPermissions.Contains(p))
+ )
{
await audit.LogAsync(
- role.Id,
+ id,
adminUserId,
"RolePermissionAdded",
$"Permission '{perm}' added to role '{role.Name}'"
@@ -172,34 +172,35 @@ await audit.LogAsync(
async Task (
string id,
HttpContext context,
- RoleManager roleManager,
- UserManager userManager,
+ IRoleAdminContracts roleAdmin,
IPermissionContracts permissionContracts,
AuditService audit
) =>
{
- var role = await roleManager.FindByIdAsync(id);
+ var role = await roleAdmin.GetRoleByIdAsync(id);
if (role is null)
return TypedResults.NotFound();
- var usersInRole = await userManager.GetUsersInRoleAsync(role.Name!);
- if (usersInRole.Count > 0)
+ var hasUsers = await roleAdmin.HasUsersInRoleAsync(id);
+ if (hasUsers)
return TypedResults.BadRequest(
new { error = "Cannot delete a role that has users assigned to it." }
);
// Remove role permissions first
- await permissionContracts.SetPermissionsForRoleAsync(RoleId.From(id), []);
+ var roleId = RoleId.From(id);
+ await permissionContracts.SetPermissionsForRoleAsync(roleId, []);
- var adminUserId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "";
+ var adminUserId =
+ context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "";
await audit.LogAsync(
- role.Id,
+ id,
adminUserId,
"RoleDeleted",
$"Role '{role.Name}' deleted"
);
- await roleManager.DeleteAsync(role);
+ await roleAdmin.DeleteRoleAsync(id);
return TypedResults.Redirect("/admin/roles");
}
diff --git a/modules/Admin/src/SimpleModule.Admin/Endpoints/Admin/AdminUsersEndpoint.cs b/modules/Admin/src/SimpleModule.Admin/Endpoints/Admin/AdminUsersEndpoint.cs
index fb08b2f4..c05a5dd9 100644
--- a/modules/Admin/src/SimpleModule.Admin/Endpoints/Admin/AdminUsersEndpoint.cs
+++ b/modules/Admin/src/SimpleModule.Admin/Endpoints/Admin/AdminUsersEndpoint.cs
@@ -1,15 +1,12 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
-using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using SimpleModule.Admin.Services;
using SimpleModule.Core;
using SimpleModule.Permissions.Contracts;
-using SimpleModule.Users;
using SimpleModule.Users.Contracts;
-using SimpleModule.Users.Entities;
namespace SimpleModule.Admin.Endpoints.Admin;
@@ -31,35 +28,28 @@ public void Map(IEndpointRouteBuilder app)
[FromForm] string password,
[FromForm] bool emailConfirmed,
HttpContext context,
- UserManager userManager,
+ IUserAdminContracts userAdmin,
AuditService audit
) =>
{
var adminId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "";
- var user = new ApplicationUser
- {
- UserName = email,
- Email = email,
- DisplayName = displayName,
- EmailConfirmed = emailConfirmed,
- };
-
- var result = await userManager.CreateAsync(user, password);
- if (!result.Succeeded)
- {
- return TypedResults.Redirect("/admin/users?error=create-failed");
- }
-
var form = await context.Request.ReadFormAsync();
var filteredRoles = form["roles"]
.Where(r => !string.IsNullOrEmpty(r))
.Select(r => r!)
.ToList();
- if (filteredRoles.Count > 0)
+
+ var request = new CreateAdminUserRequest
{
- await userManager.AddToRolesAsync(user, filteredRoles);
- }
+ Email = email,
+ DisplayName = displayName,
+ Password = password,
+ EmailConfirmed = emailConfirmed,
+ Roles = filteredRoles,
+ };
+
+ var user = await userAdmin.CreateUserWithPasswordAsync(request);
await audit.LogAsync(user.Id, adminId, "UserCreated", $"Created user {email}");
@@ -76,23 +66,20 @@ async Task (
[FromForm] string email,
[FromForm] string? emailConfirmed,
HttpContext context,
- UserManager userManager,
+ IUserAdminContracts userAdmin,
AuditService audit
) =>
{
var adminId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "";
- var user = await userManager.FindByIdAsync(id);
- if (user is null)
- {
- return TypedResults.NotFound();
- }
- user.DisplayName = displayName;
- user.Email = email;
- user.UserName = email;
- user.EmailConfirmed = emailConfirmed is not null;
+ var request = new UpdateAdminUserRequest
+ {
+ DisplayName = displayName,
+ Email = email,
+ EmailConfirmed = emailConfirmed is not null,
+ };
- await userManager.UpdateAsync(user);
+ await userAdmin.UpdateUserDetailsAsync(UserId.From(id), request);
await audit.LogAsync(
id,
adminId,
@@ -110,44 +97,25 @@ await audit.LogAsync(
async Task (
string id,
HttpContext context,
- UserManager userManager,
+ IUserAdminContracts userAdmin,
AuditService audit
) =>
{
var adminId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "";
- var user = await userManager.FindByIdAsync(id);
- if (user is null)
- {
- return TypedResults.NotFound();
- }
var form = await context.Request.ReadFormAsync();
var newRoles = form["roles"]
.Where(r => !string.IsNullOrEmpty(r))
.Select(r => r!)
- .ToHashSet();
- var currentRoles = (await userManager.GetRolesAsync(user)).ToHashSet();
-
- var toRemove = currentRoles.Except(newRoles).ToList();
- var toAdd = newRoles.Except(currentRoles).ToList();
-
- if (toRemove.Count > 0)
- {
- await userManager.RemoveFromRolesAsync(user, toRemove);
- foreach (var role in toRemove)
- {
- await audit.LogAsync(id, adminId, "RoleRemoved", $"Removed role {role}");
- }
- }
+ .ToList();
- if (toAdd.Count > 0)
- {
- await userManager.AddToRolesAsync(user, toAdd);
- foreach (var role in toAdd)
- {
- await audit.LogAsync(id, adminId, "RoleAdded", $"Added role {role}");
- }
- }
+ await userAdmin.SetUserRolesAsync(UserId.From(id), newRoles);
+ await audit.LogAsync(
+ id,
+ adminId,
+ "RolesUpdated",
+ $"Set roles to [{string.Join(", ", newRoles)}]"
+ );
return TypedResults.Redirect($"/admin/users/{id}/edit?tab=roles");
}
@@ -209,19 +177,13 @@ async Task (
string id,
[FromForm] string newPassword,
HttpContext context,
- UserManager userManager,
+ IUserAdminContracts userAdmin,
AuditService audit
) =>
{
var adminId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "";
- var user = await userManager.FindByIdAsync(id);
- if (user is null)
- {
- return TypedResults.NotFound();
- }
- var token = await userManager.GeneratePasswordResetTokenAsync(user);
- await userManager.ResetPasswordAsync(user, token, newPassword);
+ await userAdmin.ResetPasswordAsync(UserId.From(id), newPassword);
await audit.LogAsync(id, adminId, "PasswordReset");
return TypedResults.Redirect($"/admin/users/{id}/edit?tab=security");
@@ -234,19 +196,13 @@ AuditService audit
async Task (
string id,
HttpContext context,
- UserManager userManager,
+ IUserAdminContracts userAdmin,
AuditService audit
) =>
{
var adminId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "";
- var user = await userManager.FindByIdAsync(id);
- if (user is null)
- {
- return TypedResults.NotFound();
- }
- await userManager.SetLockoutEnabledAsync(user, true);
- await userManager.SetLockoutEndDateAsync(user, DateTimeOffset.UtcNow.AddYears(100));
+ await userAdmin.LockAccountAsync(UserId.From(id));
await audit.LogAsync(id, adminId, "AccountLocked");
return TypedResults.Redirect($"/admin/users/{id}/edit?tab=security");
@@ -259,19 +215,13 @@ AuditService audit
async Task (
string id,
HttpContext context,
- UserManager userManager,
+ IUserAdminContracts userAdmin,
AuditService audit
) =>
{
var adminId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "";
- var user = await userManager.FindByIdAsync(id);
- if (user is null)
- {
- return TypedResults.NotFound();
- }
- await userManager.SetLockoutEndDateAsync(user, null);
- await userManager.ResetAccessFailedCountAsync(user);
+ await userAdmin.UnlockAccountAsync(UserId.From(id));
await audit.LogAsync(id, adminId, "AccountUnlocked");
return TypedResults.Redirect($"/admin/users/{id}/edit?tab=security");
@@ -284,19 +234,13 @@ AuditService audit
async Task (
string id,
HttpContext context,
- UserManager userManager,
+ IUserAdminContracts userAdmin,
AuditService audit
) =>
{
var adminId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "";
- var user = await userManager.FindByIdAsync(id);
- if (user is null)
- {
- return TypedResults.NotFound();
- }
- user.EmailConfirmed = false;
- await userManager.UpdateAsync(user);
+ await userAdmin.ForceEmailReverificationAsync(UserId.From(id));
await audit.LogAsync(id, adminId, "EmailReverified");
return TypedResults.Redirect($"/admin/users/{id}/edit?tab=security");
@@ -309,19 +253,13 @@ AuditService audit
async Task (
string id,
HttpContext context,
- UserManager userManager,
+ IUserAdminContracts userAdmin,
AuditService audit
) =>
{
var adminId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "";
- var user = await userManager.FindByIdAsync(id);
- if (user is null)
- {
- return TypedResults.NotFound();
- }
- await userManager.SetTwoFactorEnabledAsync(user, false);
- await userManager.ResetAuthenticatorKeyAsync(user);
+ await userAdmin.DisableTwoFactorAsync(UserId.From(id));
await audit.LogAsync(id, adminId, "TwoFactorDisabled");
return TypedResults.Redirect($"/admin/users/{id}/edit?tab=security");
@@ -334,21 +272,13 @@ AuditService audit
async Task (
string id,
HttpContext context,
- UserManager userManager,
+ IUserAdminContracts userAdmin,
AuditService audit
) =>
{
var adminId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "";
- var user = await userManager.FindByIdAsync(id);
- if (user is null)
- {
- return TypedResults.NotFound();
- }
- user.DeactivatedAt = DateTimeOffset.UtcNow;
- await userManager.UpdateAsync(user);
- await userManager.SetLockoutEnabledAsync(user, true);
- await userManager.SetLockoutEndDateAsync(user, DateTimeOffset.UtcNow.AddYears(100));
+ await userAdmin.DeactivateAsync(UserId.From(id));
await audit.LogAsync(id, adminId, "UserDeactivated");
return TypedResults.Redirect($"/admin/users/{id}/edit?tab=details");
@@ -361,21 +291,13 @@ AuditService audit
async Task (
string id,
HttpContext context,
- UserManager userManager,
+ IUserAdminContracts userAdmin,
AuditService audit
) =>
{
var adminId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "";
- var user = await userManager.FindByIdAsync(id);
- if (user is null)
- {
- return TypedResults.NotFound();
- }
- user.DeactivatedAt = null;
- await userManager.UpdateAsync(user);
- await userManager.SetLockoutEndDateAsync(user, null);
- await userManager.ResetAccessFailedCountAsync(user);
+ await userAdmin.ReactivateAsync(UserId.From(id));
await audit.LogAsync(id, adminId, "UserReactivated");
return TypedResults.Redirect($"/admin/users/{id}/edit?tab=details");
diff --git a/modules/Admin/src/SimpleModule.Admin/SimpleModule.Admin.csproj b/modules/Admin/src/SimpleModule.Admin/SimpleModule.Admin.csproj
index a7fb309c..bcdfbdc3 100644
--- a/modules/Admin/src/SimpleModule.Admin/SimpleModule.Admin.csproj
+++ b/modules/Admin/src/SimpleModule.Admin/SimpleModule.Admin.csproj
@@ -7,7 +7,7 @@
-
+
diff --git a/modules/Admin/src/SimpleModule.Admin/Views/Admin/RolesCreateEndpoint.cs b/modules/Admin/src/SimpleModule.Admin/Views/Admin/RolesCreateEndpoint.cs
index f0ef1ef1..0e94e825 100644
--- a/modules/Admin/src/SimpleModule.Admin/Views/Admin/RolesCreateEndpoint.cs
+++ b/modules/Admin/src/SimpleModule.Admin/Views/Admin/RolesCreateEndpoint.cs
@@ -6,12 +6,13 @@
namespace SimpleModule.Admin.Views.Admin;
+[ViewPage("Admin/Admin/RolesCreate")]
public class RolesCreateEndpoint : IViewEndpoint
{
public void Map(IEndpointRouteBuilder app)
{
app.MapGet(
- "/admin/roles/create",
+ "/roles/create",
(PermissionRegistry permissionRegistry) =>
{
var permissionsByModule = permissionRegistry.ByModule.ToDictionary(
diff --git a/modules/Admin/src/SimpleModule.Admin/Views/Admin/RolesEditEndpoint.cs b/modules/Admin/src/SimpleModule.Admin/Views/Admin/RolesEditEndpoint.cs
index f6b851c5..655880eb 100644
--- a/modules/Admin/src/SimpleModule.Admin/Views/Admin/RolesEditEndpoint.cs
+++ b/modules/Admin/src/SimpleModule.Admin/Views/Admin/RolesEditEndpoint.cs
@@ -1,46 +1,33 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
-using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Routing;
-using Microsoft.EntityFrameworkCore;
using SimpleModule.Core;
using SimpleModule.Core.Authorization;
using SimpleModule.Core.Inertia;
using SimpleModule.Permissions.Contracts;
-using SimpleModule.Users;
-using SimpleModule.Users.Entities;
+using SimpleModule.Users.Contracts;
namespace SimpleModule.Admin.Views.Admin;
+[ViewPage("Admin/Admin/RolesEdit")]
public class RolesEditEndpoint : IViewEndpoint
{
public void Map(IEndpointRouteBuilder app)
{
app.MapGet(
- "/admin/roles/{id}/edit",
+ "/roles/{id}/edit",
async (
string id,
- RoleManager roleManager,
- UserManager userManager,
+ IRoleAdminContracts roleAdmin,
IPermissionContracts permissionContracts,
PermissionRegistry permissionRegistry,
string? tab
) =>
{
- var role = await roleManager.FindByIdAsync(id);
+ var role = await roleAdmin.GetRoleByIdAsync(id);
if (role is null)
return TypedResults.NotFound();
- var usersInRole = await userManager.GetUsersInRoleAsync(role.Name!);
- var users = usersInRole
- .Select(u => new
- {
- id = u.Id,
- displayName = u.DisplayName,
- email = u.Email,
- })
- .ToList();
-
var rolePermissions = (
await permissionContracts.GetPermissionsForRoleAsync(RoleId.From(id))
).ToList();
@@ -54,14 +41,8 @@ await permissionContracts.GetPermissionsForRoleAsync(RoleId.From(id))
"Admin/Admin/RolesEdit",
new
{
- role = new
- {
- id = role.Id,
- name = role.Name,
- description = role.Description,
- createdAt = role.CreatedAt.ToString("O"),
- },
- users,
+ role,
+ users = Array.Empty