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.