diff --git a/.claude/commands/ci.md b/.claude/commands/ci.md
index 57ffade7..924fa955 100644
--- a/.claude/commands/ci.md
+++ b/.claude/commands/ci.md
@@ -4,7 +4,7 @@ Execute these steps in order, stopping on first failure. Report results as a sum
## Step 1: Lint & Format Check
-Run `npm run check` from the project root. This runs biome lint, format check, page validation, and i18n validation.
+Run `npm run check` from the project root. This runs biome lint + format check, page validation (`validate-pages`), i18n validation (`validate:i18n`), framework-scope validation (`validate:framework-scope`), and TypeScript type checking (`typecheck`).
## Step 2: Frontend Build
diff --git a/.claude/commands/debug-module.md b/.claude/commands/debug-module.md
index b5693023..7ee36ff9 100644
--- a/.claude/commands/debug-module.md
+++ b/.claude/commands/debug-module.md
@@ -43,10 +43,10 @@ Read `modules/{Name}/src/SimpleModule.{Name}/SimpleModule.{Name}.csproj`.
Verify:
- The file exists.
-- `Sdk="Microsoft.NET.Sdk"` (must NOT be `Microsoft.NET.Sdk.Razor` or `Microsoft.NET.Sdk.Web`).
+- `Sdk="Microsoft.NET.Sdk.StaticWebAssets"` (the implementation project ships the module's static frontend bundle; must NOT be `Microsoft.NET.Sdk.Razor` or `Microsoft.NET.Sdk.Web`).
- Contains ``.
-Fix if failing: Set `Sdk="Microsoft.NET.Sdk"` if it is wrong. If `` is missing, add it inside an ``.
+Fix if failing: Set `Sdk="Microsoft.NET.Sdk.StaticWebAssets"` if it is wrong. If `` is missing, add it inside an ``.
---
@@ -80,18 +80,24 @@ Run:
dotnet build --no-incremental 2>&1 | grep -E "SM0|error" | head -50
```
-Surface any SM00xx source generator diagnostic codes. Their meanings:
+Surface any SM00xx source generator diagnostic codes. Codes currently span **SM0001–SM0061** (non-contiguous); `docs/CONSTITUTION.md` is the authoritative catalog. The most commonly hit:
| Code | Meaning |
|------|---------|
-| SM0001 | Module class must be public and non-sealed |
-| SM0010 | IEndpoint implementation must be internal sealed |
-| SM0020 | IViewEndpoint implementation must be internal sealed |
-| SM0030 | [Dto] types must live in a Contracts assembly |
-| SM0040 | No impl→impl project references allowed between modules |
-| SM0044 | Inertia.Render component name not found in Pages/index.ts |
-
-Fix each reported code using the guidance above before continuing.
+| SM0011 | Direct module→module implementation reference (reference the `.Contracts` project instead) |
+| SM0014 | Referenced Contracts assembly has no public interfaces |
+| SM0025 | No implementation found for a contract interface (create the `{Name}ContractsService`) |
+| SM0032 | Permission class is not `sealed` |
+| SM0040 | Duplicate module name across modules |
+| SM0041 | View page name not prefixed with the module name |
+| SM0042 | Module has view endpoints but no `ViewPrefix` on `[Module]` |
+| SM0043 | Module overrides no `IModule` method |
+| SM0049 | More than one endpoint class in a single file |
+| SM0054 | Endpoint missing `public const string Route` (Info) |
+| SM0056 / SM0057 | `[FormRequest]` class not sealed / not extending `FormRequest` |
+| SM0058–SM0061 | Policy class rules (resource not a `[Dto]`, not public, foreign module, or generic) |
+
+For any code not listed here, look it up in `docs/CONSTITUTION.md`. Fix each reported code before continuing.
---
@@ -102,13 +108,13 @@ Run:
npm run validate-pages
```
-If it exits with an error, identify each missing entry and show the exact line to add to `modules/{Name}/src/SimpleModule.{Name}/Pages/index.ts`:
+If it exits with an error, identify each missing entry and show the exact line to add to `modules/{Name}/src/SimpleModule.{Name}/Pages/index.ts` (pages are co-located in `Pages/`, so the import path is relative to that folder):
```typescript
-'{Name}/{ViewName}': () => import('../Views/{ViewName}'),
+'{Name}/{ViewName}': () => import('./{ViewName}'),
```
-Derive `{ViewName}` from the component name reported missing by `npm run validate-pages` (e.g., if it reports `Products/Browse`, the ViewName is `Browse`).
+Derive `{ViewName}` from the component name reported missing by `npm run validate-pages` (e.g., if it reports `AuditLogs/Browse`, the ViewName is `Browse`).
Add the missing entries to the `pages` record in `Pages/index.ts`, then re-run `npm run validate-pages` to confirm it passes.
diff --git a/.claude/commands/review-module.md b/.claude/commands/review-module.md
index 16280f92..f9e003cb 100644
--- a/.claude/commands/review-module.md
+++ b/.claude/commands/review-module.md
@@ -34,11 +34,15 @@ Check each match:
3. **RequirePermission** — Endpoints must use `.RequirePermission(...)` not bare `.RequireAuthorization()`. Flag any `.RequireAuthorization()` call without a permission argument.
+4. **Route const** — Every endpoint class must declare `public const string Route = {Name}Constants.Routes.X;` and pass `Route` to its `MapXxx` call (enforced by **SM0054**). Flag any endpoint that hardcodes the route literal in the `MapXxx` call instead of declaring/using the const.
+
+5. **FormRequest binding** — Form posts should bind a `[FormRequest]` class from the module's `FormRequests/` folder, declared `sealed partial` and extending `FormRequest` (**SM0056** / **SM0057**), bound directly as a handler parameter. Flag `[FormRequest]` classes that are not sealed or do not extend `FormRequest`.
+
---
## Area 3 — Naming conventions
-Grep for class definitions (`class `) in `modules/{Name}/src/SimpleModule.{Name}/Endpoints/` and `modules/{Name}/src/SimpleModule.{Name}/Views/`.
+Grep for class definitions (`class `) in `modules/{Name}/src/SimpleModule.{Name}/Endpoints/` and `modules/{Name}/src/SimpleModule.{Name}/Pages/`.
For each source file found:
@@ -60,26 +64,26 @@ Compare the two sets: every component key from an `Inertia.Render` call must app
Then run `npm run validate-pages` for authoritative output and report any additional mismatches it finds.
-Violation fix: Add a matching entry to `Pages/index.ts`:
+Violation fix: Add a matching entry to `Pages/index.ts` (pages are co-located in `Pages/`):
```typescript
-"{Name}/{ViewName}": () => import("../Views/{ViewName}"),
+"{Name}/{ViewName}": () => import("./{ViewName}"),
```
---
-## Area 5 — Events: Proper definition and registration
+## Area 5 — Events: Wolverine definition and handler conventions
-Grep for `: IEvent` in `modules/{Name}/src/SimpleModule.{Name}.Contracts/`.
+Events use [Wolverine](https://wolverinefx.net/) in-process messaging — there is **no** `IEventHandler<>` interface and **no** DI registration for handlers.
-For each event type found:
+Grep for `: DomainEvent` in `modules/{Name}/src/SimpleModule.{Name}.Contracts/`.
-1. **Record type** — events must be `record` types, not `class`. Flag any `class` that implements `IEvent`.
+For each event type found:
-Grep for `IEventHandler<` in `modules/{Name}/src/SimpleModule.{Name}/`.
+1. **Record + Contracts** — events must be `record` types inheriting `DomainEvent` (from `SimpleModule.Core.Events`) and live in the `.Contracts` assembly. Flag any `class`-based event, any event missing the `DomainEvent` base, or any event defined in the implementation assembly.
-For each handler found, check that a corresponding `services.AddScoped, THandler>()` call exists in `ConfigureServices` (or wherever DI is configured in the module class). Flag any handler with no registration.
+Handlers are auto-discovered by Wolverine convention — class name ending in `Handler`/`Consumer`, method named `Handle`/`HandleAsync`/`Consume`/`ConsumeAsync`, first parameter is the event, remaining parameters resolved from DI.
-Violation fix for missing registration: Add `services.AddScoped, {HandlerType}>();` in `ConfigureServices`.
+2. **Handler convention** — flag any would-be handler whose class/method names don't match the convention (and isn't opted in with `[WolverineHandler]`), since it will silently never fire. Do **not** flag a missing `services.AddScoped(...)` registration — handlers need none.
---
@@ -87,7 +91,7 @@ Violation fix for missing registration: Add `services.AddScoped()` is absent from `{Name}Module.cs`: flag it as a violation.
- - If no permissions class exists and no `AddPermissions` call exists: mark Area 7 as N/A.
+ - If no permissions class exists and no `AddPermissions` call exists: mark this check as N/A.
- If both exist and the type matches: OK.
+**Policies (instance-level authorization).** Grep for `: IPolicy<` in `modules/{Name}/src/SimpleModule.{Name}/`. For each policy class found:
+
+4. **Policy rules** — the class must be `public sealed`, non-generic, and target a resource type that is a contracts `[Dto]` owned by **this** module (enforced by **SM0058**–**SM0061**). Flag any policy that is non-public, generic, targets a non-`[Dto]` or foreign-module resource. Policies are auto-discovered — do **not** flag a missing DI registration. If the module has no `IPolicy<>` classes, mark this check as N/A.
+
---
## Final Report
diff --git a/.claude/skills/minimal-api/SKILL.md b/.claude/skills/minimal-api/SKILL.md
index c8687d53..54fab9f3 100644
--- a/.claude/skills/minimal-api/SKILL.md
+++ b/.claude/skills/minimal-api/SKILL.md
@@ -7,7 +7,8 @@ description: >
CrudEndpoints helpers, Inertia.Render, or form handling. Triggers on: "add endpoint",
"create endpoint", "new endpoint", "API endpoint", "view endpoint", "MapGet", "MapPost",
"MapPut", "MapDelete", "Inertia.Render", route handling, parameter binding questions,
- or any work touching files in Endpoints/ or Pages/ directories.
+ "FormRequest", "Route const", "IAuthorizer", "policy", "RequirePermission",
+ or any work touching files in Endpoints/, Pages/, or FormRequests/ directories.
---
# SimpleModule Minimal API Endpoints
@@ -23,10 +24,26 @@ Both implement `void Map(IEndpointRouteBuilder app)`. The source generator disco
## Endpoint File Structure
-One endpoint per file. Class name = `{Action}Endpoint`. Place in:
+One endpoint per file (enforced by **SM0049**). Class name = `{Action}Endpoint`. Place in:
- `Endpoints/{Feature}/` for `IEndpoint`
- `Pages/` for `IViewEndpoint` (co-located with its `.tsx` component; optionally grouped in feature subfolders)
+### Route const (required)
+
+Every endpoint declares a `public const string Route` and passes it to its `MapXxx` call — enforced by **SM0054**. Route values live in a `Routes` nested class on the module constants:
+
+```csharp
+public class GetAllProductsEndpoint : IEndpoint
+{
+ public const string Route = ProductsConstants.Routes.GetAll;
+
+ public void Map(IEndpointRouteBuilder app) =>
+ app.MapGet(Route, (IProductContracts contracts) =>
+ CrudEndpoints.GetAll(contracts.GetAllAsync))
+ .RequirePermission(ProductsPermissions.View);
+}
+```
+
## Parameter Binding Rules
### Implicit binding (no attribute needed)
@@ -76,6 +93,10 @@ CrudEndpoints.Update(() => contracts.UpdateAsync(id, request))
// DELETE
CrudEndpoints.Delete(() => contracts.DeleteAsync(id))
+
+// Soft-delete-aware modules also have (204 on success, 404 if no rows affected):
+CrudEndpoints.Restore(() => contracts.RestoreAsync(id))
+CrudEndpoints.ForceDelete(() => contracts.ForceDeleteAsync(id))
```
## View Endpoint Patterns
@@ -84,14 +105,37 @@ CrudEndpoints.Delete(() => contracts.DeleteAsync(id))
// Render page with props (props serialize to camelCase)
Inertia.Render("Module/PageName", new { items = await svc.GetAllAsync() })
-// Form: GET renders form, POST processes submission
-app.MapGet("/create", () => Inertia.Render("Module/Create"));
-app.MapPost("/", async ([FromForm] string name, [FromForm] decimal price, ISvc svc) => {
- await svc.CreateAsync(new CreateRequest { Name = name, Price = price });
+// Form: GET renders form, POST binds + validates a [FormRequest] directly
+app.MapGet(Route, () => Inertia.Render("Module/Create"));
+app.MapPost("/things", async (CreateThingFormRequest form, ISvc svc) => {
+ await svc.CreateAsync(new CreateRequest { Name = form.Name, Price = form.Price });
return Results.Redirect("/module/manage");
-}).DisableAntiforgery();
+});
```
+### Form binding with `FormRequest`
+
+Form posts bind a `[FormRequest]` class — `sealed partial`, extending `FormRequest` (**SM0056** / **SM0057**), in the module's `FormRequests/` folder. The framework hydrates it from the form body, runs `Prepare()`, then validates via `ConfigureRules` before the handler runs (no manual `IValidator`, no `.DisableAntiforgery()`):
+
+```csharp
+using FluentValidation;
+using SimpleModule.Core.FormRequests;
+
+[FormRequest]
+public sealed partial class CreateThingFormRequest : FormRequest
+{
+ public string Name { get; set; } = "";
+ public decimal Price { get; set; }
+
+ public override void Prepare() => Name = Name.Trim();
+
+ protected override void ConfigureRules(RuleConfigurator rules) =>
+ rules.RuleFor(x => x.Name).NotEmpty().WithMessage("Name is required.");
+}
+```
+
+For one-off forms, a private `sealed record` of `[FromForm]` fields bound with `[AsParameters]` (plus manual `IValidator`) still works — see [references/patterns.md](references/patterns.md).
+
**Critical**: Every `IViewEndpoint` with `Inertia.Render("Module/Page", ...)` MUST have a matching entry in `Pages/index.ts`. Run `npm run validate-pages` to verify.
**Note**: CA1812 ("internal class never instantiated") is suppressed via `.editorconfig` for `Endpoints/` and `Pages/` (`*Endpoint.cs`) directories. No `[SuppressMessage]` attributes needed on payload classes.
@@ -111,6 +155,14 @@ app.MapPost("/", async ([FromForm] string name, [FromForm] decimal price, ISvc s
Note: `.RequireAuthorization()` is already applied to the route group by the source generator. Endpoint-level auth narrows or overrides this.
+For **per-resource** rules (ownership, tenancy, state) beyond the coarse permission gate, inject `IAuthorizer` and call it after loading the resource — it dispatches to the resource's `IPolicy` (deny-wins; throws `ForbiddenException` / `NotFoundException` on deny):
+
+```csharp
+await authorizer.AuthorizeAsync(user, PolicyActions.Update, product);
+```
+
+See the `simplemodule` skill's "Policies" section (diagnostics SM0058–SM0061).
+
## Validation
Use [FluentValidation](https://docs.fluentvalidation.net/). Define a `sealed` validator extending `AbstractValidator`, register it once per module with `services.AddValidatorsFromAssemblyContaining()` in `ConfigureServices`, then inject `IValidator` into the endpoint handler:
diff --git a/.claude/skills/minimal-api/references/patterns.md b/.claude/skills/minimal-api/references/patterns.md
index ff020632..bbec613d 100644
--- a/.claude/skills/minimal-api/references/patterns.md
+++ b/.claude/skills/minimal-api/references/patterns.md
@@ -18,15 +18,17 @@
## Full CRUD Endpoint Set
-Each operation is a separate file in `Endpoints/{Feature}/`.
+Each operation is a separate file in `Endpoints/{Feature}/` (one endpoint per file, **SM0049**). Every endpoint declares a `public const string Route` and passes it to its `MapXxx` call (**SM0054**); route literals live in a `Routes` nested class on the module constants.
### GetAllEndpoint.cs
```csharp
public class GetAllEndpoint : IEndpoint
{
+ public const string Route = ProductsConstants.Routes.GetAll;
+
public void Map(IEndpointRouteBuilder app) =>
app.MapGet(
- "/",
+ Route,
(IProductContracts productContracts) =>
CrudEndpoints.GetAll(productContracts.GetAllProductsAsync)
)
@@ -38,9 +40,11 @@ public class GetAllEndpoint : IEndpoint
```csharp
public class GetByIdEndpoint : IEndpoint
{
+ public const string Route = ProductsConstants.Routes.GetById;
+
public void Map(IEndpointRouteBuilder app) =>
app.MapGet(
- "/{id}",
+ Route,
(ProductId id, IProductContracts productContracts) =>
CrudEndpoints.GetById(() => productContracts.GetProductByIdAsync(id))
)
@@ -52,9 +56,11 @@ public class GetByIdEndpoint : IEndpoint
```csharp
public class CreateEndpoint : IEndpoint
{
+ public const string Route = ProductsConstants.Routes.Create;
+
public void Map(IEndpointRouteBuilder app) =>
app.MapPost(
- "/",
+ Route,
async (
CreateProductRequest request,
IValidator validator,
@@ -79,9 +85,11 @@ public class CreateEndpoint : IEndpoint
```csharp
public class UpdateEndpoint : IEndpoint
{
+ public const string Route = ProductsConstants.Routes.Update;
+
public void Map(IEndpointRouteBuilder app) =>
app.MapPut(
- "/{id}",
+ Route,
async (
ProductId id,
UpdateProductRequest request,
@@ -106,9 +114,11 @@ public class UpdateEndpoint : IEndpoint
```csharp
public class DeleteEndpoint : IEndpoint
{
+ public const string Route = ProductsConstants.Routes.Delete;
+
public void Map(IEndpointRouteBuilder app) =>
app.MapDelete(
- "/{id}",
+ Route,
(ProductId id, IProductContracts productContracts) =>
CrudEndpoints.Delete(() => productContracts.DeleteProductAsync(id))
)
@@ -116,37 +126,77 @@ public class DeleteEndpoint : IEndpoint
}
```
+For soft-delete-aware modules, `CrudEndpoints` also exposes `Restore(() => contracts.RestoreAsync(id))` and `ForceDelete(() => contracts.ForceDeleteAsync(id))`.
+
---
## View Endpoint with Form Submission
-GET renders the form page, POST handles the form data. Form data ALWAYS needs `[FromForm]`.
+GET renders the form page; POST binds and validates the submission. The preferred binding is a `[FormRequest]` class — `sealed partial`, extending `FormRequest` (**SM0056** / **SM0057**), in the module's `FormRequests/` folder. It binds **directly** as a handler parameter: the framework hydrates it from the form body, runs `Prepare()`, then validates via `ConfigureRules` before the handler runs (no manual `IValidator`, no `.DisableAntiforgery()`).
```csharp
+// FormRequests/CreateProductFormRequest.cs
+using FluentValidation;
+using SimpleModule.Core.FormRequests;
+
+[FormRequest]
+public sealed partial class CreateProductFormRequest : FormRequest
+{
+ public string Name { get; set; } = "";
+ public decimal Price { get; set; }
+
+ public override void Prepare() => Name = Name.Trim();
+
+ protected override void ConfigureRules(RuleConfigurator rules)
+ {
+ rules.RuleFor(x => x.Name).NotEmpty().WithMessage("Product name is required.");
+ rules.RuleFor(x => x.Price).GreaterThan(0).WithMessage("Price must be greater than zero.");
+ }
+}
+
+// Pages/CreateEndpoint.cs
public class CreateEndpoint : IViewEndpoint
{
+ public const string Route = ProductsConstants.Routes.CreatePage;
+
public void Map(IEndpointRouteBuilder app)
{
- app.MapGet("/create", () => Inertia.Render("Products/Create"));
+ app.MapGet(Route, () => Inertia.Render("Products/Create"));
app.MapPost(
- "/",
- async (
- [FromForm] string name,
- [FromForm] decimal price,
- IProductContracts products
- ) =>
+ "/products",
+ async (CreateProductFormRequest form, IProductContracts products) =>
{
- var request = new CreateProductRequest { Name = name, Price = price };
- await products.CreateProductAsync(request);
+ await products.CreateProductAsync(
+ new CreateProductRequest { Name = form.Name, Price = form.Price });
return Results.Redirect("/products/manage");
}
- )
- .DisableAntiforgery();
+ );
}
}
```
+### Alternative: inline `[FromForm]` record
+
+For one-off forms, bind a private `sealed record` of `[FromForm]` fields with `[AsParameters]` and validate manually with `IValidator` (as the Email module's `EditTemplateEndpoint` does):
+
+```csharp
+app.MapPost(
+ "/templates/{id}",
+ async (int id, [AsParameters] UpdateTemplateForm form,
+ IValidator validator, IEmailContracts email) =>
+ {
+ var request = new UpdateEmailTemplateRequest { Name = form.Name, Subject = form.Subject };
+ var validation = await validator.ValidateAsync(request);
+ if (!validation.IsValid)
+ throw new Core.Exceptions.ValidationException(validation.ToValidationErrors());
+ await email.UpdateTemplateAsync(EmailTemplateId.From(id), request);
+ return Results.Redirect("/email/templates");
+ });
+
+// private sealed record UpdateTemplateForm([FromForm] string Name, [FromForm] string Subject);
+```
+
---
## Payload Transformation
diff --git a/.claude/skills/new-module/SKILL.md b/.claude/skills/new-module/SKILL.md
index aa6cac8e..85ca4f85 100644
--- a/.claude/skills/new-module/SKILL.md
+++ b/.claude/skills/new-module/SKILL.md
@@ -147,3 +147,7 @@ Run `dotnet build` to confirm the source generator discovers the new module.
- Constants go in the Contracts project (shared dependency)
- Use `ConfigureServices(IServiceCollection services, IConfiguration configuration)` signature (two params)
- Follow naming: `PascalCase` public, `_camelCase` private fields, file-scoped namespaces
+
+## Adding endpoints later
+
+When you add endpoints to the module, follow the current conventions (see the `minimal-api` skill): one endpoint class per file (**SM0049**), each declaring a `public const string Route = {Name}Constants.Routes.X;` passed to its `MapXxx` call (**SM0054**), with route literals centralized in a `Routes` nested class on `{Name}Constants`. Bind form posts with a `[FormRequest]` class (`sealed partial : FormRequest`, **SM0056** / **SM0057**) in a `FormRequests/` folder. Register every `IViewEndpoint` page in `Pages/index.ts`.
diff --git a/.claude/skills/simplemodule/SKILL.md b/.claude/skills/simplemodule/SKILL.md
index cc81719c..04c65ead 100644
--- a/.claude/skills/simplemodule/SKILL.md
+++ b/.claude/skills/simplemodule/SKILL.md
@@ -9,7 +9,8 @@ description: >
"event bus", "permissions", "menu", "settings", "database context", "contracts",
"IModule", "IEndpoint", "IViewEndpoint", "Inertia", "CrudEndpoints",
"debug module", "review module", "module status", "SM00", "source generator diagnostic",
- "module not found", "page 404", "event bus", "settings", "IMessageBus", "Wolverine", "SettingDefinition".
+ "module not found", "page 404", "event bus", "settings", "IMessageBus", "Wolverine", "SettingDefinition",
+ "policy", "IPolicy", "IAuthorizer", "entity-level authorization", "FormRequest", "Route const".
---
# SimpleModule Framework Guide
@@ -147,6 +148,8 @@ public class {Name}Module : IModule
## Endpoint Patterns
+Every endpoint (`IEndpoint` / `IViewEndpoint`) declares a `public const string Route` and passes it to its `MapXxx` call (enforced by **SM0054**); one endpoint class per file (**SM0049**). Form posts bind a `[FormRequest]` class — `sealed partial`, extending `FormRequest` (**SM0056** / **SM0057**) — directly as a handler parameter, with validation auto-run before the handler.
+
See [references/endpoints.md](references/endpoints.md) for complete endpoint patterns.
## Database & Data Access
@@ -188,6 +191,7 @@ When adding a new module, ensure:
| SM0014 diagnostic | No public interfaces in Contracts assembly | Add `I{Name}Contracts` to the Contracts project |
| SM0025 diagnostic | No implementation for contract interface | Create `{Name}ContractsService` implementing `I{Name}Contracts` and register in `ConfigureServices` |
| SM0041/SM0042 diagnostic | View endpoint misconfigured | Ensure `Inertia.Render` name is prefixed with module name; add `ViewPrefix` to `[Module]` attribute |
+| SM0054 diagnostic | Endpoint has no `Route` const | Add `public const string Route = {Name}Constants.Routes.X;` and pass `Route` to the `MapXxx` call |
| `TreatWarningsAsErrors` build failure | Nullable, unused variable, or analyzer warning | Fix the warning; suppress in `.editorconfig` only if genuinely intentional |
| Event handler never called | Class/method doesn't match Wolverine's convention | Name the class `*Handler`/`*Consumer` with a `Handle`/`Consume` method (first param = the event); Wolverine auto-discovers it — no DI registration |
| Cross-module data wrong | Injecting impl class directly | Always inject `I{Name}Contracts` interface, never the concrete service class |
@@ -265,9 +269,39 @@ var max = await _settings.GetSettingAsync("Orders.MaxItemsPerOrder", Settin
**Scopes:** `System` = application-wide (e.g., feature flags), `Application` = per tenant/deployment, `User` = per authenticated user.
+## Policies (instance-level authorization)
+
+String permissions (`.RequirePermission(...)`) are the coarse capability gate. **Policies** add per-resource rules — ownership, tenancy, state-machine checks. Declare a `public sealed class {Name}Policy : IPolicy<{Resource}Dto>` in the module that owns the resource; the source generator auto-discovers and registers it as a scoped service (no manual registration).
+
+```csharp
+using SimpleModule.Core.Authorization.Policies;
+
+public sealed class ProductPolicy : IPolicy
+{
+ public Task AuthorizeAsync(
+ ClaimsPrincipal user, string action, ProductDto resource, CancellationToken ct = default)
+ {
+ var result = action switch
+ {
+ PolicyActions.Update or PolicyActions.Delete =>
+ resource.OwnerId == user.GetUserId()
+ ? AuthorizationResult.Allow()
+ : AuthorizationResult.Deny("You can only modify your own products."),
+ PolicyActions.View => AuthorizationResult.Allow(),
+ _ => AuthorizationResult.Deny($"Unknown action '{action}'."),
+ };
+ return Task.FromResult(result);
+ }
+}
+```
+
+Check it from an endpoint or service by injecting `IAuthorizer` and calling `AuthorizeAsync(user, PolicyActions.Update, resource)` (throws `ForbiddenException`, or `NotFoundException` for anti-enumeration via `AuthorizationResult.DenyAsNotFound(...)`), or `CheckAsync(...)` for a non-throwing `AuthorizationResult`. Semantics are **deny-wins**: every registered policy must allow; the first deny short-circuits.
+
+Diagnostics: resource type must be a contracts `[Dto]` (**SM0058**), policy class must be `public` (**SM0059**) and owned by the resource's module (**SM0060**), and must not be generic (**SM0061**).
+
## Constitution Diagnostics (SM00xx)
-The source generator enforces these rules at build time. Run `/debug-module {Name}` to check all at once.
+The source generator enforces these rules at build time. Run `/debug-module {Name}` to check all at once. Codes currently span **SM0001–SM0061** (non-contiguous); the most commonly hit are:
| Code | Rule | Common cause |
|------|------|-------------|
@@ -283,6 +317,11 @@ The source generator enforces these rules at build time. Run `/debug-module {Nam
| SM0041 | View page name must be prefixed with module name | Inertia component name doesn't start with the module name |
| SM0042 | `ViewPrefix` required when module has view endpoints | Module has `IViewEndpoint`s but no `ViewPrefix` on `[Module]` |
| SM0043 | Module must override at least one `IModule` method | Empty or placeholder module class |
+| SM0049 | One endpoint class per file | Two endpoint classes declared in the same `.cs` file |
+| SM0054 | Endpoint missing `Route` const | Endpoint has no `public const string Route` field (Info) |
+| SM0056 | FormRequest class must be `sealed` | `[FormRequest]` class is not sealed |
+| SM0057 | FormRequest must extend `FormRequest` | `[FormRequest]` class has the wrong base type |
+| SM0058–SM0061 | Policy class rules | Resource not a `[Dto]`, policy not public, policy in foreign module, or generic policy |
For the full list of diagnostics, see `docs/CONSTITUTION.md`.
diff --git a/.claude/skills/simplemodule/references/cross-module.md b/.claude/skills/simplemodule/references/cross-module.md
index bfccff52..e655a156 100644
--- a/.claude/skills/simplemodule/references/cross-module.md
+++ b/.claude/skills/simplemodule/references/cross-module.md
@@ -125,7 +125,7 @@ public void ConfigurePermissions(PermissionRegistryBuilder builder)
### Use on Endpoints
```csharp
-app.MapGet("/", handler).RequirePermission(ProductsPermissions.View);
+app.MapGet(Route, handler).RequirePermission(ProductsPermissions.View);
```
### Wildcard Matching
@@ -133,6 +133,10 @@ app.MapGet("/", handler).RequirePermission(ProductsPermissions.View);
- `"*"` matches any single-segment permission
- Users with "Admin" role bypass all permission checks
+## Policies (instance-level authorization)
+
+Permissions are coarse capability gates. **Policies** add per-resource rules (ownership, tenancy, state). Declare `public sealed class {Name}Policy : IPolicy<{Resource}Dto>` in the module that **owns** the resource — the resource type must be a contracts `[Dto]` (**SM0058**) and the policy must live in the resource's owning module (**SM0060**), since policies run with deny-wins semantics across modules. They are auto-discovered (no registration). Check them by injecting `IAuthorizer` and calling `AuthorizeAsync(user, PolicyActions.Update, resource)`. See the SimpleModule skill's "Policies" section.
+
## Settings
### Define Settings
diff --git a/.claude/skills/simplemodule/references/database.md b/.claude/skills/simplemodule/references/database.md
index 93ba0fd1..74e4fc42 100644
--- a/.claude/skills/simplemodule/references/database.md
+++ b/.claude/skills/simplemodule/references/database.md
@@ -43,6 +43,20 @@ Register in the module's `ConfigureServices`:
services.AddModuleDbContext(configuration, ProductsConstants.ModuleName);
```
+The helper has two optional parameters:
+
+```csharp
+services.AddModuleDbContext(
+ configuration,
+ ProductsConstants.ModuleName,
+ configureOptions: opts => opts.UseOpenIddict(), // optional: extra DbContextOptions config
+ enableSpatial: true // optional: NetTopologySuite for GIS/PostGIS
+);
+```
+
+- `configureOptions` — an `Action` for provider-specific setup (e.g. `UseOpenIddict()`).
+- `enableSpatial` — enables NetTopologySuite spatial types when `true` (default `false`).
+
## Schema Isolation
- **PostgreSQL/SQL Server**: Uses database schemas (e.g., `products.Products`)
diff --git a/.claude/skills/simplemodule/references/endpoints.md b/.claude/skills/simplemodule/references/endpoints.md
index fccb4c19..99f4ac77 100644
--- a/.claude/skills/simplemodule/references/endpoints.md
+++ b/.claude/skills/simplemodule/references/endpoints.md
@@ -17,12 +17,16 @@ One endpoint per file. Class name = `{Action}Endpoint`. Place in:
## API Endpoints with CrudEndpoints Helper
+Every endpoint declares a `public const string Route` (enforced by **SM0054**) and passes it to the `MapXxx` call. Route values are centralized in a `Routes` nested class on the module's constants (e.g. `ProductsConstants.Routes.GetAll`).
+
```csharp
// GET all — returns 200 OK
public class GetAllEndpoint : IEndpoint
{
+ public const string Route = ProductsConstants.Routes.GetAll;
+
public void Map(IEndpointRouteBuilder app) =>
- app.MapGet("/", (IProductContracts contracts) =>
+ app.MapGet(Route, (IProductContracts contracts) =>
CrudEndpoints.GetAll(contracts.GetAllProductsAsync))
.RequirePermission(ProductsPermissions.View);
}
@@ -30,8 +34,10 @@ public class GetAllEndpoint : IEndpoint
// GET by ID — returns 200 OK or 404 NotFound
public class GetByIdEndpoint : IEndpoint
{
+ public const string Route = ProductsConstants.Routes.GetById;
+
public void Map(IEndpointRouteBuilder app) =>
- app.MapGet("/{id}", (ProductId id, IProductContracts contracts) =>
+ app.MapGet(Route, (ProductId id, IProductContracts contracts) =>
CrudEndpoints.GetById(() => contracts.GetProductByIdAsync(id)))
.RequirePermission(ProductsPermissions.View);
}
@@ -39,8 +45,10 @@ public class GetByIdEndpoint : IEndpoint
// POST create — returns 201 Created with Location header
public class CreateEndpoint : IEndpoint
{
+ public const string Route = ProductsConstants.Routes.Create;
+
public void Map(IEndpointRouteBuilder app) =>
- app.MapPost("/", async (CreateProductRequest request, IValidator validator, IProductContracts contracts) =>
+ app.MapPost(Route, async (CreateProductRequest request, IValidator validator, IProductContracts contracts) =>
{
var validation = await validator.ValidateAsync(request);
if (!validation.IsValid) throw new Core.Exceptions.ValidationException(validation.ToValidationErrors());
@@ -54,8 +62,10 @@ public class CreateEndpoint : IEndpoint
// PUT update — returns 200 OK
public class UpdateEndpoint : IEndpoint
{
+ public const string Route = ProductsConstants.Routes.Update;
+
public void Map(IEndpointRouteBuilder app) =>
- app.MapPut("/{id}", async (ProductId id, UpdateProductRequest request, IValidator validator, IProductContracts contracts) =>
+ app.MapPut(Route, async (ProductId id, UpdateProductRequest request, IValidator validator, IProductContracts contracts) =>
{
var validation = await validator.ValidateAsync(request);
if (!validation.IsValid) throw new Core.Exceptions.ValidationException(validation.ToValidationErrors());
@@ -67,21 +77,27 @@ public class UpdateEndpoint : IEndpoint
// DELETE — returns 204 NoContent
public class DeleteEndpoint : IEndpoint
{
+ public const string Route = ProductsConstants.Routes.Delete;
+
public void Map(IEndpointRouteBuilder app) =>
- app.MapDelete("/{id}", (ProductId id, IProductContracts contracts) =>
+ app.MapDelete(Route, (ProductId id, IProductContracts contracts) =>
CrudEndpoints.Delete(() => contracts.DeleteProductAsync(id)))
.RequirePermission(ProductsPermissions.Delete);
}
```
+`CrudEndpoints` also provides `Restore(() => contracts.RestoreAsync(id))` and `ForceDelete(() => contracts.ForceDeleteAsync(id))` for soft-delete-aware modules.
+
## View Endpoints with Inertia
```csharp
// Browse page (public)
public class BrowseEndpoint : IViewEndpoint
{
+ public const string Route = ProductsConstants.Routes.Browse;
+
public void Map(IEndpointRouteBuilder app) =>
- app.MapGet("/browse", async (IProductContracts products) =>
+ app.MapGet(Route, async (IProductContracts products) =>
Inertia.Render("Products/Browse",
new { products = await products.GetAllProductsAsync() }))
.AllowAnonymous();
@@ -90,22 +106,52 @@ public class BrowseEndpoint : IViewEndpoint
// Form page with GET (render) + POST (submit)
public class CreateEndpoint : IViewEndpoint
{
+ public const string Route = ProductsConstants.Routes.CreatePage;
+
public void Map(IEndpointRouteBuilder app)
{
- app.MapGet("/create", () => Inertia.Render("Products/Create"));
+ app.MapGet(Route, () => Inertia.Render("Products/Create"));
- app.MapPost("/", async (
- [FromForm] string name,
- [FromForm] decimal price,
- IProductContracts products) =>
+ // Bind + validate a [FormRequest] directly (see "Form Binding" below).
+ app.MapPost("/products", async (CreateProductFormRequest form, IProductContracts products) =>
{
- await products.CreateProductAsync(new CreateProductRequest { Name = name, Price = price });
+ await products.CreateProductAsync(new CreateProductRequest { Name = form.Name, Price = form.Price });
return Results.Redirect("/products/manage");
- }).DisableAntiforgery();
+ });
+ }
+}
+```
+
+A view endpoint with several routes (GET page + POST submit + DELETE) still declares a single `public const string Route` for the primary route; the remaining `MapXxx` calls may use literals or additional consts.
+
+## Form Binding (FormRequest)
+
+For form posts, declare a `[FormRequest]` class in the module's `FormRequests/` folder. It must be `sealed partial` and extend `FormRequest` (enforced by **SM0056** / **SM0057**). Bind it **directly** as a handler parameter — the framework hydrates it from the form body, runs `Prepare()`, then validates via `ConfigureRules` before your handler executes. No manual `IValidator` call and no `.DisableAntiforgery()` needed.
+
+```csharp
+using FluentValidation;
+using SimpleModule.Core.FormRequests;
+
+namespace SimpleModule.Products.FormRequests;
+
+[FormRequest]
+public sealed partial class CreateProductFormRequest : FormRequest
+{
+ public string Name { get; set; } = "";
+ public decimal Price { get; set; }
+
+ public override void Prepare() => Name = Name.Trim();
+
+ protected override void ConfigureRules(RuleConfigurator rules)
+ {
+ rules.RuleFor(x => x.Name).NotEmpty().WithMessage("Name is required.");
+ rules.RuleFor(x => x.Price).GreaterThan(0).WithMessage("Price must be greater than zero.");
}
}
```
+For one-off forms you can still bind a private `sealed record` of `[FromForm]` fields via `[AsParameters]` and validate manually with `IValidator`, but `FormRequest` is preferred for anything reused or validated.
+
## Parameter Binding Rules
### Implicit (no attribute needed)
@@ -148,6 +194,23 @@ var body = await JsonSerializer.DeserializeAsync(context.Request.Body);
`.RequireAuthorization()` is already applied to the route group by the source generator.
+### Instance-level authorization (policies)
+
+`.RequirePermission(...)` is the coarse capability gate. For per-resource rules (ownership, tenancy, state), inject `IAuthorizer` and call it after loading the resource — it dispatches to the resource type's `IPolicy` with deny-wins semantics:
+
+```csharp
+async (ProductId id, IAuthorizer authorizer, ClaimsPrincipal user, IProductContracts products) =>
+{
+ var product = await products.GetProductByIdAsync(id);
+ if (product is null) return Results.NotFound();
+ // Throws ForbiddenException (or NotFoundException for anti-enumeration actions) on deny.
+ await authorizer.AuthorizeAsync(user, PolicyActions.Update, product);
+ // ... proceed
+}
+```
+
+See the SimpleModule skill's "Policies" section for declaring `IPolicy` (diagnostics SM0058–SM0061).
+
## Validation
Use FluentValidation `AbstractValidator`. Register via `services.AddValidatorsFromAssemblyContaining()` in `ConfigureServices`. Inject `IValidator` into the endpoint handler.
diff --git a/.claude/skills/simplemodule/references/frontend.md b/.claude/skills/simplemodule/references/frontend.md
index de375817..3fe0c08e 100644
--- a/.claude/skills/simplemodule/references/frontend.md
+++ b/.claude/skills/simplemodule/references/frontend.md
@@ -86,7 +86,7 @@ Each module uses a minimal Vite config:
```typescript
// vite.config.ts
import { defineModuleConfig } from '@simplemodule/client/module';
-export default defineModuleConfig(__dirname);
+export default defineModuleConfig(import.meta.dirname);
```
This configures library mode, externalizes React/React-DOM/@inertiajs/react, and outputs `{Name}.pages.js` to `wwwroot/`.
diff --git a/.claude/skills/verify-feature/SKILL.md b/.claude/skills/verify-feature/SKILL.md
index d2efac5a..3a56fb44 100644
--- a/.claude/skills/verify-feature/SKILL.md
+++ b/.claude/skills/verify-feature/SKILL.md
@@ -13,7 +13,7 @@ Run this skill after a feature is implemented and committed locally. It is a gat
Before starting, confirm these from conversation context (do not ask the user unless missing):
- **Feature description** — what was implemented (used to pick the page and assertions for stage 2)
-- **Page route** — the Inertia route (e.g. `Products/Browse`) or URL path to exercise
+- **Page route** — the Inertia route (e.g. `AuditLogs/Browse`) or URL path to exercise
- **Branch** — current branch (must not be `main`)
If route/path is unknown, grep the diff for `Inertia.Render("...")` calls to infer it.