@drunkcoding/dknet-implementation-skills 0.1.0 → 14.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +37 -26
  3. package/package.json +2 -3
  4. package/{.claude-plugin → plugin/.claude-plugin}/plugin.json +1 -1
  5. package/{agents → plugin/agents}/dknet-architect.md +4 -4
  6. package/{agents → plugin/agents}/dknet-bdd-engineer.md +6 -6
  7. package/{agents → plugin/agents}/dknet-implementer.md +11 -11
  8. package/{skills → plugin/skills}/README.md +13 -12
  9. package/{skills → plugin/skills}/dknet-auth-and-ownership/SKILL.md +100 -36
  10. package/{skills → plugin/skills}/dknet-bdd-tests/SKILL.md +25 -16
  11. package/{skills → plugin/skills}/dknet-bdd-tests/checklist.md +1 -1
  12. package/{skills → plugin/skills}/dknet-crud/SKILL.md +34 -22
  13. package/{skills → plugin/skills}/dknet-ddd-principles/SKILL.md +62 -13
  14. package/{skills → plugin/skills}/dknet-docs/SKILL.md +73 -31
  15. package/{skills → plugin/skills}/dknet-docs/templates/README-template.md +8 -8
  16. package/{skills → plugin/skills}/dknet-docs/templates/api-reference-template.md +7 -0
  17. package/{skills → plugin/skills}/dknet-docs/templates/architecture-template.md +14 -8
  18. package/{skills → plugin/skills}/dknet-docs/templates/data-model-template.md +2 -2
  19. package/{skills → plugin/skills}/dknet-docs/templates/events-template.md +2 -2
  20. package/{skills → plugin/skills}/dknet-dto-mapping/SKILL.md +37 -29
  21. package/{skills → plugin/skills}/dknet-efcore-config/SKILL.md +38 -38
  22. package/{skills → plugin/skills}/dknet-endpoint/SKILL.md +70 -43
  23. package/{skills → plugin/skills}/dknet-entity/SKILL.md +43 -33
  24. package/{skills → plugin/skills}/dknet-feature/SKILL.md +34 -13
  25. package/{skills → plugin/skills}/dknet-feature-lifecycle/SKILL.md +48 -42
  26. package/{skills → plugin/skills}/dknet-feature-remove/SKILL.md +15 -15
  27. package/{skills → plugin/skills}/dknet-messaging-events/SKILL.md +36 -29
  28. package/{skills → plugin/skills}/dknet-package-adoption/SKILL.md +3 -3
  29. package/{skills → plugin/skills}/dknet-platform-config/SKILL.md +14 -14
  30. package/{skills → plugin/skills}/dknet-project-structure/SKILL.md +32 -32
  31. package/{skills → plugin/skills}/dknet-queries-specs/SKILL.md +21 -13
  32. package/{skills → plugin/skills}/dknet-scaffold/SKILL.md +3 -2
  33. package/{skills → plugin/skills}/dknet-unit-tests/SKILL.md +27 -22
  34. package/plugin.json +3 -3
  35. /package/{skills → plugin/skills}/dknet-docs/checklist.md +0 -0
@@ -12,7 +12,7 @@
12
12
  "plugins": [
13
13
  {
14
14
  "name": "dknet-minimal",
15
- "source": ".",
15
+ "source": "./plugin",
16
16
  "description": "Skills (Claude Code plugin + Agent Skills standard) and subagents for building vertical-slice features on the DKNet.Minimal.Template: endpoints, actions, queries, validation, DTO mapping, entities, seeding, SlimMessageBus/Azure Service Bus events, auth/ownership, platform flags, tests, and end-to-end /dknet-feature workflows.",
17
17
  "category": "development",
18
18
  "homepage": "https://github.com/baoduy/DKNet.Templates",
package/README.md CHANGED
@@ -59,8 +59,8 @@ Every scaffold parameter, plus the run/test/migrate/pack commands:
59
59
 
60
60
  ## The shape your endpoints should take
61
61
 
62
- **Composite-first.** One endpoint class per aggregate: map the generated CRUD composite at the top,
63
- each generated route carrying its own authorization, then hand-write below it only the routes that
62
+ **Composite-first.** One endpoint class per aggregate: declare the group's authorization scopes on the
63
+ class, map the generated CRUD composite at the top, then hand-write below it only the routes that
64
64
  carry real orchestration. Generated and hand-written routes live in the same group — this is not a
65
65
  choice between two styles of endpoint.
66
66
 
@@ -68,29 +68,38 @@ choice between two styles of endpoint.
68
68
  trimmed here to its structure:
69
69
 
70
70
  ```csharp
71
- public void Map(RouteGroupBuilder group)
71
+ [EndpointGroupScope(ProductScopes.Read, EndpointHttpMethods.Get)]
72
+ [EndpointGroupScope(ProductScopes.Write, EndpointHttpMethods.Post, EndpointHttpMethods.Put,
73
+ EndpointHttpMethods.Delete)]
74
+ internal sealed class ProductV1Endpoint : IEndpointConfig
72
75
  {
73
- group.MapProductCrud(o =>
76
+ public void Map(RouteGroupBuilder group)
74
77
  {
75
- o.Exclude("Discontinue"); // hand-written below
76
-
77
- o.Configure(CrudOp.GetById, rb => rb.RequireAuthorization(ProductScopes.Read));
78
- o.Configure(CrudOp.Create, rb => rb.RequireAuthorization(ProductScopes.Write));
79
- o.Configure("Approve", rb => rb.RequireAuthorization(ProductScopes.Write));
80
- // its own scope — holding products.write must not be enough to assign a supplier reference
81
- o.Configure("AssignSupplierReference", rb => rb.RequireAuthorization(ProductScopes.Supplier));
82
- });
83
-
84
- // writes two aggregates in one transaction — outside what the generator can express
85
- group.MapPut("{id:guid}/discontinue", /* ... */).Produces<ProductDto>();
86
-
87
- // a response shape the generator has none for
88
- group.MapGet("summary", /* ... */).Produces<ProductPriceSummaryDto>();
78
+ group.MapProductCrud(o =>
79
+ {
80
+ o.Exclude("Discontinue"); // hand-written below
81
+
82
+ // a PUT like Update, so the per-method declaration cannot separate them — holding
83
+ // products.write must not be enough to assign a supplier reference
84
+ if (requireAuthorization)
85
+ o.Configure("AssignSupplierReference", rb => rb.RequireAuthorization(ProductScopes.Supplier));
86
+ });
87
+
88
+ // writes two aggregates in one transaction — outside what the generator can express
89
+ group.MapPut("{id:guid}/discontinue", /* ... */).Produces<ProductDto>();
90
+
91
+ // a response shape the generator has none for; the group's GET declaration already covers it
92
+ group.MapGet("summary", /* ... */).Produces<ProductPriceSummaryDto>();
93
+ }
89
94
  }
90
95
  ```
91
96
 
92
- Seven of the nine routes come from `MapProductCrud`. Scopes are applied only when
93
- `FeatureManagement:RequireAuthorization` is on, so a stock Development run enforces none of them.
97
+ Seven of the nine routes come from `MapProductCrud`. `[EndpointGroupScope]` names the scope per HTTP
98
+ method once and covers generated and hand-mapped routes alike; a per-route `Configure`/
99
+ `RequireAuthorization` is needed only where two routes share an HTTP method but not a scope. Scopes
100
+ are applied only when `FeatureManagement:RequireAuthorization` is on, so a stock Development run
101
+ enforces none of them — the attribute checks that flag itself, which is why the per-route overrides
102
+ are the only calls that need an explicit guard.
94
103
 
95
104
  ### When an operation needs a hand-written route
96
105
 
@@ -160,15 +169,17 @@ capability-to-attribute tables ("I want X; which attribute or call gives it to m
160
169
 
161
170
  ## AI Plugin — Claude Code, GitHub Copilot, and any agent that reads SKILL.md
162
171
 
163
- This repository root is also the `dknet-minimal` plugin: `.claude-plugin/plugin.json` + `skills/` +
164
- `agents/` (Claude Code), `plugin.json` (GitHub Copilot), `package.json` (npm). One set of
172
+ The `dknet-minimal` plugin lives in [`plugin/`](plugin): `plugin/.claude-plugin/plugin.json` +
173
+ `plugin/skills/` + `plugin/agents/` (Claude Code). The repository root keeps only the entry points
174
+ that have to be found there — `.claude-plugin/marketplace.json` (pointing at `./plugin`),
175
+ `plugin.json` (GitHub Copilot) and `package.json` (npm). One set of
165
176
  [Agent Skills](https://agentskills.io) teaches an AI coding agent how to build on this template: the
166
177
  layer boundaries, every endpoint shape (hand-mapped, generated `Map<Entity>Crud()`, or the generic
167
178
  route helpers), actions and queries, FluentValidation + Mapster DTOs, domain entities and static
168
179
  seeding, SlimMessageBus internal events and Azure Service Bus forwarding, auth/ownership and every
169
180
  `FeatureManagement` flag. Eight of the skills are slash workflows that scaffold a vertical slice end to
170
181
  end; three Claude Code subagents (`dknet-architect`, `dknet-implementer`, `dknet-bdd-engineer`) back
171
- `/dknet-feature`. Index of every skill: [`skills/README.md`](skills/README.md).
182
+ `/dknet-feature`. Index of every skill: [`plugin/skills/README.md`](plugin/skills/README.md).
172
183
 
173
184
  `dotnet new dknet-minimal` does **not** copy the plugin into a generated solution (only `AGENTS.md`
174
185
  ships) — install it into the generated repo with one of the channels below.
@@ -200,14 +211,14 @@ npx skills add baoduy/DKNet.Templates -s '*' -y # everything, no prompts
200
211
 
201
212
  ```bash
202
213
  npm i -D @drunkcoding/dknet-implementation-skills
203
- claude --plugin-dir node_modules/@drunkcoding/dknet-implementation-skills # Claude Code
214
+ claude --plugin-dir node_modules/@drunkcoding/dknet-implementation-skills/plugin # Claude Code
204
215
  npx skills experimental_sync -a '*' # any agent: node_modules -> .agents/skills/ etc.
205
216
  ```
206
217
 
207
218
  **Working on this repository**
208
219
 
209
220
  ```bash
210
- claude --plugin-dir . # loads skills/ and agents/ from the checkout
221
+ claude --plugin-dir plugin # loads plugin/skills/ and plugin/agents/ from the checkout
211
222
  ./validate-plugin.sh # manifests, README install channels, skill portability
212
223
  ```
213
224
 
@@ -243,7 +254,7 @@ claude --plugin-dir . # loads skills/ and agents/ from the checkout
243
254
 
244
255
  **Release.** Versions stay `0.0.0` in git. `publish-nuget-github.yml` computes the release version from
245
256
  tags on `main`, packs and publishes the NuGet template, creates the GitHub release, then stamps the same
246
- version into `package.json` / `plugin.json` / `.claude-plugin/plugin.json` (`npm version` →
257
+ version into `package.json` / `plugin.json` / `plugin/.claude-plugin/plugin.json` (`npm version` →
247
258
  `scripts/sync-version.mjs`), validates the skills (`agentskills validate`, `claude plugin validate`,
248
259
  `npx skills add --list`, `./validate-plugin.sh`) and publishes `@drunkcoding/dknet-implementation-skills` to npm
249
260
  with OIDC trusted publishing. A version already on npm is skipped.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drunkcoding/dknet-implementation-skills",
3
- "version": "0.1.0",
3
+ "version": "14.1.1",
4
4
  "description": "Agent skills (Claude Code plugin + Agent Skills standard) that teach coding agents how to build features on solutions generated from DKNet.Minimal.Template. Install with `npx skills add baoduy/DKNet.Templates`.",
5
5
  "keywords": [
6
6
  "dknet",
@@ -36,8 +36,7 @@
36
36
  },
37
37
  "files": [
38
38
  ".claude-plugin/",
39
- "skills/",
40
- "agents/",
39
+ "plugin/",
41
40
  "plugin.json",
42
41
  "README.md",
43
42
  "LICENSE"
@@ -3,7 +3,7 @@
3
3
  "name": "dknet-minimal",
4
4
  "displayName": "DKNet Minimal Template",
5
5
  "description": "Skills and subagents that teach an agent how to build features on the DKNet.Minimal.Template (.NET 10, vertical-slice DDD/CQRS): endpoints (hand-mapped, generated CRUD, generic helpers), actions and queries, FluentValidation + Mapster DTOs, domain entities and static seeding, SlimMessageBus internal and Azure Service Bus events, auth/ownership and every platform flag — plus slash workflows that scaffold a slice end-to-end.",
6
- "version": "0.1.0",
6
+ "version": "14.1.1",
7
7
  "author": {
8
8
  "name": "baoduy",
9
9
  "email": "baoduy2412@gmail.com",
@@ -19,19 +19,19 @@ Always start by reading:
19
19
  - the `dknet-ddd-principles` skill for aggregate boundary, entity-vs-value-object, invariant, and domain-event judgment calls — apply these when deciding what the new aggregate owns and what triggers an event.
20
20
  - `CLAUDE.md` and `AGENTS.md` for current layer rules and conventions.
21
21
  - The two existing exemplar slices, and the `dknet-feature-lifecycle` skill §1 for the layer-by-layer trade-off between them:
22
- - Hand-written — `Minimal.Domains/Features/ManualSample/Entities/PurchaseOrder.cs`, `Minimal.Infra/Features/ManualSample/`, `Minimal.AppServices/ManualSample/V1/`, `Minimal.Api/ApiEndpoints/ManualSample/PurchaseOrderV1Endpoint.cs`.
23
- - Generator-driven — `Minimal.Domains/Features/AutomatedSample/Entities/Product.cs` (`[RaisesEvent]`/`[CrudCreate]`/`[CrudUpdate]`), `Minimal.AppServices/AutomatedSample/V1/ProductDto.cs` (`[GenerateDto]`), `Minimal.Api/ApiEndpoints/AutomatedSample/ProductV1Endpoint.cs`.
22
+ - Hand-written — `<YourApp>.Domains/Features/ManualSample/Entities/PurchaseOrder.cs`, `<YourApp>.Infra/Features/ManualSample/`, `<YourApp>.AppServices/ManualSample/V1/`, `<YourApp>.Api/ApiEndpoints/ManualSample/PurchaseOrderV1Endpoint.cs`.
23
+ - Generator-driven — `<YourApp>.Domains/Features/AutomatedSample/Entities/Product.cs` (`[RaisesEvent]`/`[CrudCreate]`/`[CrudUpdate]`), `<YourApp>.AppServices/AutomatedSample/V1/ProductDto.cs` (`[GenerateDto]`), `<YourApp>.Api/ApiEndpoints/AutomatedSample/ProductV1Endpoint.cs`.
24
24
  - The skill that matches the layer you're planning (the `dknet-entity` skill, `dknet-efcore-config`, `dknet-crud`, `dknet-endpoint`, `dknet-bdd-tests`, `dknet-unit-tests`).
25
25
 
26
26
  ## Output contract
27
27
 
28
28
  Produce a single markdown plan with these sections, no more no less:
29
29
 
30
- 1. **Aggregates & owned types** — name, schema prefix for `DomainSchemas`, immutable vs. mutable fields, mutation methods, sequence usage. State explicitly which fields are entities vs. value objects and why (per `dknet-ddd-principles`), and what the aggregate's consistency boundary is. Also state up front which shape this feature should take: hand-written (`PurchaseOrder`-style — needed for idempotent writes, an operation that writes more than one aggregate in one transaction, filtered queries, or a DTO that hides fields; a rule that merely refuses an operation is a FluentValidation validator on the generated request instead) or generator-driven (`Product`-style — a genuinely plain CRUD entity whose validation is fully expressible as DataAnnotations). The `dknet-feature-lifecycle` skill §1 is the deciding reference.
30
+ 1. **Aggregates & owned types** — name, schema prefix for `DomainSchemas`, immutable vs. mutable fields, mutation methods, sequence usage. State explicitly which fields are entities vs. value objects and why (per `dknet-ddd-principles`), and what the aggregate's consistency boundary is. Also state up front which shape this feature should take. **Default to generator-driven** (`Product`-style: `[CrudCreate]`/`[CrudUpdate]`/`[CrudAction]`/`[RaisesEvent]` + `[GenerateDto]`). Propose hand-written (`PurchaseOrder`-style) only for idempotent writes, an operation that writes more than one aggregate in one transaction, a query beyond the generic list route's `filter`/`search`/`orderBy` contract, `[FromClaim]` acting-user attribution, or attribute-declared validation that must return `400`. A rule that merely refuses an operation is a FluentValidation validator on the generated request, and a DTO that hides fields is `[GenerateDto(..., Exclude = [...])]` — neither forces the hand-written shape. The `dknet-feature-lifecycle` skill §1 is the deciding reference.
31
31
  2. **EF Core mapping** — table name, indexes, max lengths, column types, owned-type registrations, seed data. Both sample shapes hand-write this layer — no generator touches `IEntityTypeConfiguration<T>`.
32
32
  3. **AppServices actions (V1)** — for the hand-written shape: for each of Create/Update/Delete, request shape, validator rules, duplicate spec, domain events emitted, lazy-mapping decision (mirror `PurchaseOrder`'s `Actions/Create.cs`/`Update.cs`/`Cancel.cs`/`Delete.cs`). For the generator-driven shape: which entity members carry `[CrudCreate]`/`[CrudUpdate]`/`[RaisesEvent]`, and the one-line `[GenerateDto(typeof(Entity))]` DTO — flag explicitly that generated-route validation is enforced only when the entity's endpoint uses literal `Map*(string, Delegate)` calls, not the generic `Map*<TRequest,TDto>` wrapper (see `Product`'s confirmed-live gap: `POST /v1/products` with a negative price returns `201`).
33
33
  4. **Query specs** — `SpecGet<Entity>` constructor parameters; expected callers. N/A for the generator-driven shape (GetById/GetList map straight to `DKNet.AspCore.Extensions`'s generic `MapGetById`/`MapGetList` — no per-entity query object exists).
34
- 5. **Endpoint contract** — `IEndpointConfig` group path, version, mapping style (literal `group.MapPost/MapGet/MapPut/MapDelete` for hand-written, or the generated `Map<Entity>Crud()` extension for generator-driven), idempotency requirements (`.RequiredIdempotentKey()` for POST — required whenever the plan calls for idempotent writes; the generated CRUD route does not add this), auth/`RequireAuthorization` decisions.
34
+ 5. **Endpoint contract** — `IEndpointConfig` group path, version, mapping style (literal `group.MapPost/MapGet/MapPut/MapDelete` for hand-written, or the generated `Map<Entity>Crud()` extension for generator-driven), idempotency requirements (`.RequiredIdempotentKey()` for POST — required whenever the plan calls for idempotent writes; the generated CRUD route does not add this), auth decisions — name the scope per HTTP method so it can be declared with `[EndpointGroupScope]` on the endpoint class, and call out any route whose scope its HTTP method cannot decide, since that one needs a per-route `Configure`/`RequireAuthorization` behind the `FeatureOptions.RequireAuthorization` guard.
35
35
  6. **Tests** — unit test coverage targets (happy path, validation, duplicates, not-found, events) and BDD scenarios (happy, business-rule failure, validation failure) with key contract assertions (status, response shape, key fields).
36
36
  7. **Risks & open questions** — anything ambiguous; surface it here for the user to resolve before implementation begins.
37
37
  8. **Hand-off checklist** — explicit list of slash commands the implementer should run in order.
@@ -11,17 +11,17 @@ You are the DKNet BDD Engineer. You write Reqnroll + NUnit acceptance tests that
11
11
 
12
12
  1. the `dknet-bdd-tests` skill — the canonical pattern.
13
13
  2. the `dknet-bdd-tests` skill's `checklist.md` — the completion gate.
14
- 3. `ApiEndpoints/Minimal.App.BDDTests/Support/BddApiFactory.cs` and `ApiHooks.cs` — fixture wiring you must not duplicate.
14
+ 3. `ApiEndpoints/<YourApp>.App.BDDTests/Support/BddApiFactory.cs` and `ApiHooks.cs` — fixture wiring you must not duplicate.
15
15
  4. `specs/<feature>/contracts/*` (when present) — the source of truth for assertions.
16
16
  5. `docs/features/<feature>/` (when present) — reference context for scenario wording.
17
17
 
18
18
  ## Scope (do not stray)
19
19
 
20
20
  You may touch only:
21
- - `ApiEndpoints/Minimal.App.BDDTests/Features/**/*.feature`
22
- - `ApiEndpoints/Minimal.App.BDDTests/Features/**/Steps/*.cs`
23
- - `ApiEndpoints/Minimal.App.BDDTests/Support/*.cs` (only when adding shared step infrastructure)
24
- - `ApiEndpoints/Minimal.App.BDDTests/Minimal.App.BDDTests.csproj` (only when adding a NuGet/project ref through central package management)
21
+ - `ApiEndpoints/<YourApp>.App.BDDTests/Features/**/*.feature`
22
+ - `ApiEndpoints/<YourApp>.App.BDDTests/Features/**/Steps/*.cs`
23
+ - `ApiEndpoints/<YourApp>.App.BDDTests/Support/*.cs` (only when adding shared step infrastructure)
24
+ - `ApiEndpoints/<YourApp>.App.BDDTests/<YourApp>.App.BDDTests.csproj` (only when adding a NuGet/project ref through central package management)
25
25
 
26
26
  If a test reveals a product bug, REPORT it — do not modify domain/AppServices/Api code.
27
27
 
@@ -49,7 +49,7 @@ For every API behavior, produce at minimum:
49
49
 
50
50
  After edits:
51
51
  1. `dotnet build -c Release`
52
- 2. `dotnet test ApiEndpoints/Minimal.App.BDDTests/Minimal.App.BDDTests.csproj`
52
+ 2. `dotnet test ApiEndpoints/<YourApp>.App.BDDTests/<YourApp>.App.BDDTests.csproj`
53
53
  3. Report scenario count, pass/fail, any undefined or pending steps, and any contract gap that the spec did not cover.
54
54
 
55
55
  ## Output
@@ -24,30 +24,30 @@ Read these in order, every time:
24
24
  - the `dknet-crud` skill
25
25
  - the `dknet-endpoint` skill
26
26
  5. The exemplar slice for any layer where you're unsure — this template ships two, and the `dknet-feature-lifecycle` skill §1 is the authoritative layer-by-layer comparison between them:
27
- - **Hand-written (primary walkthrough below)** — `Minimal.Domains/Features/ManualSample/Entities/PurchaseOrder.cs`, `Minimal.Infra/Features/ManualSample/Mappers/`, `Minimal.AppServices/ManualSample/V1/Actions/`, `Specs/`, `Queries/`, `Events/`, `Minimal.Api/ApiEndpoints/ManualSample/PurchaseOrderV1Endpoint.cs`.
28
- - **Generator-driven (faster path, plain CRUD only — see below)** — `Minimal.Domains/Features/AutomatedSample/Entities/Product.cs`, `Minimal.AppServices/AutomatedSample/V1/ProductDto.cs`, `Minimal.Api/ApiEndpoints/AutomatedSample/ProductV1Endpoint.cs`.
27
+ - **Generator-driven (the default — see "Declarative path" below)** — `<YourApp>.Domains/Features/AutomatedSample/Entities/Product.cs`, `<YourApp>.AppServices/AutomatedSample/V1/ProductDto.cs`, `<YourApp>.Api/ApiEndpoints/AutomatedSample/ProductV1Endpoint.cs`.
28
+ - **Hand-written (fallback, walkthrough below)** — `<YourApp>.Domains/Features/ManualSample/Entities/PurchaseOrder.cs`, `<YourApp>.Infra/Features/ManualSample/Mappers/`, `<YourApp>.AppServices/ManualSample/V1/Actions/`, `Specs/`, `Queries/`, `Events/`, `<YourApp>.Api/ApiEndpoints/ManualSample/PurchaseOrderV1Endpoint.cs`.
29
29
 
30
30
  ## Execution order (do not skip, do not reorder)
31
31
 
32
- This is the hand-written path — follow it when the plan calls for idempotent writes, an operation that writes more than one aggregate in one transaction, a filtered query, or a DTO that hides fields (mirror `PurchaseOrder`). A rule that conditionally refuses an operation is not on that list: a FluentValidation validator written against a *generated* request runs on the generated route through the group-level `AddFluentValidationAutoValidation()` filter, which is how `Product` refuses a duplicate name and a delete of a product still for sale. For a genuinely plain CRUD entity with no such requirement, skip to "Declarative alternative" below instead of doing steps 5–6 by hand.
32
+ **Start from the declarative path below; this hand-written walkthrough is the fallback.** Follow it only when the plan calls for idempotent writes, an operation that writes more than one aggregate in one transaction, a query beyond the generic list route's `filter`/`search`/`orderBy` contract, `[FromClaim]` acting-user attribution, or attribute-declared validation that must return `400`. Neither a rule that conditionally refuses an operation nor a DTO that hides fields is on that list: the first is a FluentValidation validator written against the *generated* request, which runs on the generated route through the group-level `AddFluentValidationAutoValidation()` filter (how `Product` refuses a duplicate name and a delete of a product still for sale); the second is `[GenerateDto(..., Exclude = [...])]`. Steps 1–4 are the same either way — for the declarative path, do them and then skip to "Declarative path" instead of steps 5–6.
33
33
 
34
34
  1. **Domain** — entity (`AggregateRoot`/`DomainEntity`), owned types, `DomainSchemas` constant, sequence name (if used), domain service interface (if needed). `PurchaseOrder` raises its own creation event by calling `AddEvent(new PurchaseOrderCreatedEvent(...))` directly inside the constructor — no attribute involved.
35
35
  2. **Infra mapper** — `internal sealed : DefaultEntityTypeConfiguration<T>`, `base.Configure(builder)` first, indexes, lengths, `ToTable("...", DomainSchemas.X)` (see `PurchaseOrderConfigs`).
36
- 3. **Infra services / static seed data** — services `internal sealed` under `Minimal.Infra/Services/` and registered explicitly in `InfraSetup.AddInfraServices` (no convention scan exists); seeders `internal sealed : DataSeedingConfiguration<T>` under `Features/<X>/StaticData/` so auto-seeding picks them up (see `PurchaseOrderStaticData`). Wire `.UseAutoDataSeeding(...)` into **both** `InfraSetup.AddInfraServices` and `InfraMigration.MigrateDb` — seeding only from one of the two paths means seed rows silently never appear over HTTP (a real bug this template hit once).
37
- 4. **EF migration** — `cd ApiEndpoints && dotnet ef migrations add <Name> -c CoreDbContext -p Minimal.Infra/Minimal.Infra.csproj`. Inspect the generated migration before continuing.
36
+ 3. **Infra services / static seed data** — services `internal sealed` under `<YourApp>.Infra/Services/` and registered explicitly in `InfraSetup.AddInfraServices` (no convention scan exists); seeders `internal sealed : DataSeedingConfiguration<T>` under `Features/<X>/StaticData/` so auto-seeding picks them up (see `PurchaseOrderStaticData`). Wire `.UseAutoDataSeeding(...)` into **both** `InfraSetup.AddInfraServices` and `InfraMigration.MigrateDb` — seeding only from one of the two paths means seed rows silently never appear over HTTP (a real bug this template hit once).
37
+ 4. **EF migration** — `cd ApiEndpoints && dotnet ef migrations add <Name> -c CoreDbContext -p <YourApp>.Infra/<YourApp>.Infra.csproj`. Inspect the generated migration before continuing.
38
38
  5. **AppServices** — hand-written DTO record (no `[GenerateDto]`; see `PurchaseOrderDto` — exposes exactly the fields you write into it), `Create*Request` / `Update*Request` / `Delete*Request` (`Fluents.Requests.IWitResponse<TDto>` or `INoResponse`, `[FromClaim(ClaimTypes.Name)] ByUser` for the acting user — never trust a payload value for it), `AbstractValidator`, `internal sealed` handlers using `IRepositorySpec` + `IMapper`, `SpecGet<Entity>`, domain event record + handler.
39
39
  6. **Api endpoint** — new `*V1Endpoint : IEndpointConfig`; map every route with literal `group.MapPost/MapGet/MapPut/MapDelete(...)` calls against the raw minimal-API surface (see `PurchaseOrderV1Endpoint`). Add `.RequiredIdempotentKey()` to the POST chain — clients then send `X-Idempotency-Key: {Guid}`; a replayed key returns the original response instead of creating a duplicate.
40
40
  7. **Tests** — invoke `/dknet-unit-tests` and `/dknet-bdd-tests` (or follow the corresponding skills directly). Don't claim done until both pass.
41
41
 
42
- ## Declarative alternative — faster path for plain CRUD (`Product`)
42
+ ## Declarative path — the default (`Product`)
43
43
 
44
- For a plain CRUD entity, skip steps 2–6's AppServices/Api work almost entirely (a business rule is still allowed here — write it as a FluentValidation validator against the generated request):
44
+ Skip steps 5–6's AppServices/Api work almost entirely (a business rule is still allowed here — write it as a FluentValidation validator against the generated request):
45
45
 
46
- - `[RaisesEvent(EventOperations.Created, Include=[...])]` / `[RaisesEvent(EventOperations.Updated, nameof(Prop))]` at the class level instead of a hand-written event + `AddEvent(...)` call. Naming composes as `<Entity><NarrowingProps><Operation>Event` — e.g. `[RaisesEvent(EventOperations.Updated, nameof(Price))]` on `Product` generates `ProductPriceUpdatedEvent`, not `ProductUpdatedEvent`. Verify the composed name against the compiled assembly before wiring a consumer to it.
47
- - `[CrudCreate]` on the constructor and `[CrudUpdate]` on a mutation method — `DKNet.SlimBus.Generators` then generates the request record, handler, and route registration for you (namespace `Minimal.AppServices.Crud`, not committed — inspect `obj/Generated/DKNet.SlimBus.Generators/` after a build).
46
+ - `[RaisesEvent(EventOperations.Created, Include=[...])]` / `[RaisesEvent(EventOperations.Updated, nameof(Prop))]` at the class level instead of a hand-written event + `AddEvent(...)` call. Where a raise needs a real condition that `[RaisesEvent]` cannot express, use `AddEvent<TEvent>()` and let `IMapper` project the payload; hand-build one with `AddEvent(new …)` only when the payload is not a projection of the entity. Naming composes as `<Entity><NarrowingProps><Operation>Event` — e.g. `[RaisesEvent(EventOperations.Updated, nameof(Price))]` on `Product` generates `ProductPriceUpdatedEvent`, not `ProductUpdatedEvent`. Verify the composed name against the compiled assembly before wiring a consumer to it.
47
+ - `[CrudCreate]` on the constructor and `[CrudUpdate]` on a mutation method — `DKNet.SlimBus.Generators` then generates the request record, handler, and route registration for you (namespace `<YourApp>.AppServices.Crud`, not committed — inspect `obj/Generated/DKNet.SlimBus.Generators/` after a build).
48
48
  - `[GenerateDto(typeof(Entity))] public sealed partial record <Entity>Dto;` — one line — instead of a hand-written DTO. Generates every audited property by default; use `Exclude`/`Include` to narrow.
49
- - The endpoint becomes one `group.Map<Entity>Crud(o => …)` call instead of five hand-written `Map*` calls, with any route the generator cannot express excluded by name and hand-mapped below it (see `ProductV1Endpoint`).
50
- - **Validation-gap caveat — do not skip this:** a `[Range]`/`[Required]` on a `[CrudCreate]`/`[CrudUpdate]` parameter *is* forwarded onto the generated request property, but it is **never enforced** under this template's endpoint-registration convention — the .NET 10 validation source generator only sees literal `Map*(string, Delegate)` calls, and the generated CRUD route goes through `DKNet.AspCore.Extensions`'s generic `MapPost<TRequest,TDto>` wrapper instead. Confirmed live: `POST /v1/products` with a negative price returns `201`, not `400`. Pick this path only when that gap is acceptable, or when you plan to enforce the rule some other way. Also: `[FromClaim]` can never reach a generated request (the generator forwards only DataAnnotations attributes), so acting-user attribution goes through `DKNet.EfCore.DataAuthorization`'s `DataOwnerHook` instead — wired once in `Minimal.Api/Configs/ServiceConfigs.cs`, not per-entity.
49
+ - The endpoint becomes one `group.Map<Entity>Crud(o => …)` call instead of five hand-written `Map*` calls, with any route the generator cannot express excluded by name and hand-mapped below it (see `ProductV1Endpoint`). Declare authorization scopes with `[EndpointGroupScope]` on the endpoint class, one declaration per HTTP method (needs `DKNet.AspCore.Extensions` 13.0.0+, which this template pins); use `o.Configure(...)`/`.RequireAuthorization(...)` behind the `FeatureOptions.RequireAuthorization` guard only for a route whose HTTP method cannot decide its scope.
50
+ - **Validation-gap caveat — do not skip this:** a `[Range]`/`[Required]` on a `[CrudCreate]`/`[CrudUpdate]` parameter *is* forwarded onto the generated request property, but it is **never enforced** under this template's endpoint-registration convention — the .NET 10 validation source generator only sees literal `Map*(string, Delegate)` calls, and the generated CRUD route goes through `DKNet.AspCore.Extensions`'s generic `MapPost<TRequest,TDto>` wrapper instead. Confirmed live: `POST /v1/products` with a negative price returns `201`, not `400`. Pick this path only when that gap is acceptable, or when you plan to enforce the rule some other way. Also: `[FromClaim]` can never reach a generated request (the generator forwards only DataAnnotations attributes), so acting-user attribution goes through `DKNet.EfCore.DataAuthorization`'s `DataOwnerHook` instead — wired once in `<YourApp>.Api/Configs/ServiceConfigs.cs`, not per-entity.
51
51
 
52
52
  ## Build/verify gates
53
53
 
@@ -1,21 +1,22 @@
1
1
  # dknet-minimal skills
2
2
 
3
- Every folder here is one [Agent Skill](https://agentskills.io) (`<name>/SKILL.md`). The repository root is the
4
- `dknet-minimal` Claude Code plugin; the same files are what `npx skills add baoduy/DKNet.Templates` installs and what
5
- `@drunkcoding/dknet-minimal-skills` ships on npm. Start with `dknet-project-structure`.
3
+ Every folder here is one [Agent Skill](https://agentskills.io) (`<name>/SKILL.md`). `plugin/` is the
4
+ `dknet-minimal` Claude Code plugin (`claude --plugin-dir plugin`); the same files are what
5
+ `npx skills add baoduy/DKNet.Templates` installs and what `@drunkcoding/dknet-minimal-skills` ships on npm.
6
+ Start with `dknet-project-structure`.
6
7
 
7
8
  ## Workflows (invoke as a slash command with arguments)
8
9
 
9
10
  | Skill | Arguments | Purpose |
10
11
  |---|---|---|
11
- | `/dknet-bdd-tests` | `<Feature> e.g. Orders` | Create and maintain Reqnroll + NUnit BDD .feature scenarios and step bindings in Minimal.App.BDDTests — request/status/response-body scenarios and domain-event side effects observed via log capture, for the hand-written PurchaseOrder and generator-driven Product samples. Use when adding or updating HTTP-facing scenarios for a DKNet.Templates feature. Result-level Result-object assertions, architecture rules and pure functional tests belong in the `dknet-unit-tests` skill instead — do not duplicate a behavior here that xUnit already covers. Invoke as `/dknet-bdd-tests <Feature>` to scaffold it for a feature. |
12
+ | `/dknet-bdd-tests` | `<Feature> e.g. Orders` | Create and maintain Reqnroll + NUnit BDD .feature scenarios and step bindings in <YourApp>.App.BDDTests — request/status/response-body scenarios and domain-event side effects observed via log capture, for the hand-written PurchaseOrder and generator-driven Product samples. Use when adding or updating HTTP-facing scenarios for a DKNet.Templates feature. Result-level Result-object assertions, architecture rules and pure functional tests belong in the `dknet-unit-tests` skill instead — do not duplicate a behavior here that xUnit already covers. Invoke as `/dknet-bdd-tests <Feature>` to scaffold it for a feature. |
12
13
  | `/dknet-crud` | `<Feature> <Entity> [mode=manual\|auto] [version=V1]` | Create commands (Create/Update/business transitions/Delete) at the AppServices layer for a DKNet.Minimal feature — request contracts, FluentValidation validators, and SlimMessageBus handlers, in both the hand-written and generator-driven (CrudCreate/CrudUpdate/CrudAction) shapes. Use after the domain entity and EF Core mapper exist. Queries and paged lists are the `dknet-queries-specs` skill; response DTOs and Mapster are `dknet-dto-mapping`. Invoke as `/dknet-crud <Feature> <Entity> [mode=manual\|auto] [version=V1]` to scaffold it for a feature. |
13
- | `/dknet-docs` | `<Feature>` | Generate structured technical documentation and Mermaid architecture diagrams for completed features. Use this when documenting implemented features with README, architecture diagrams, and API references. Invoke as `/dknet-docs <Feature>` to scaffold it for a feature. |
14
+ | `/dknet-docs` | `<Feature>` | Generate structured technical documentation for a completed feature and every endpoint it exposes — README, architecture and flow diagrams drawn with archify (Mermaid as the fallback), per-route API reference, data model, and domain events. Use this when documenting an implemented feature. Invoke as `/dknet-docs <Feature>` to scaffold it for a feature. |
14
15
  | `/dknet-endpoint` | `<Feature> <Entity> [mode=manual\|auto] [routePrefix] [version=V1]` | Create Minimal API endpoint configurations using this project's IEndpointConfig pattern — raw minimal-API routes mapped by hand, a single generated Map<Entity>Crud() call, or the underlying DKNet.AspCore.Extensions generic route helpers called directly. Use after AppServices actions (or CrudCreate/CrudUpdate/CrudAction entity attributes) are ready, to expose a feature over HTTP. Invoke as `/dknet-endpoint <Feature> <Entity> [mode=manual\|auto] [routePrefix] [version=V1]` to scaffold it for a feature. |
15
- | `/dknet-entity` | `<Feature> <Entity> [mode=manual\|auto] [props…] e.g. Orders Order mode=manual Number:string Total:decimal` | Create DDD domain entities following this project's AggregateRoot/DomainEntity inheritance pattern, either hand-written (mode=manual) or generator-declared (mode=auto). Use when adding a new domain entity or owned type to Minimal.Domains. Invoke as `/dknet-entity <Feature> <Entity> [mode=manual\|auto] [props…]` to scaffold it for a feature. |
16
+ | `/dknet-entity` | `<Feature> <Entity> [mode=manual\|auto] [props…] e.g. Orders Order mode=manual Number:string Total:decimal` | Create DDD domain entities following this project's AggregateRoot/DomainEntity inheritance pattern, either hand-written (mode=manual) or generator-declared (mode=auto). Use when adding a new domain entity or owned type to <YourApp>.Domains. Invoke as `/dknet-entity <Feature> <Entity> [mode=manual\|auto] [props…]` to scaffold it for a feature. |
16
17
  | `/dknet-feature-remove` | `<Feature> [--dry-run] e.g. Orders` | Retire a DKNet vertical-slice business feature end-to-end — deletes its folders across all six projects, cleans the out-of-folder touchpoints, and drops its tables via a new migration. |
17
18
  | `/dknet-feature` | `<Feature> <Entity> [mode=manual\|auto] [props…] e.g. Orders Order mode=manual Number:string Total:decimal` | Drive an end-to-end DKNet vertical-slice feature from plan to merged tests in either the manual or automated flow — orchestrates entity, CRUD, endpoint, tests, BDD, and docs. |
18
- | `/dknet-unit-tests` | `<Feature> <Entity> [mode=manual\|auto]` | Write xUnit + Shouldly tests for a DKNet.Templates feature in Minimal.App.Tests — architecture/convention rules, pure functional tests (entity methods, validators, specs, mapping), and result-level integration tests against ApiFixture + IMessageBus (handler Result failures, EF persistence, domain events). Use after AppServices actions and endpoint config are ready. Covers only what xUnit owns; HTTP request/response and event-log scenarios belong in the `dknet-bdd-tests` skill. Invoke as `/dknet-unit-tests <Feature> <Entity> [mode=manual\|auto]` to scaffold it for a feature. |
19
+ | `/dknet-unit-tests` | `<Feature> <Entity> [mode=manual\|auto]` | Write xUnit + Shouldly tests for a DKNet.Templates feature in <YourApp>.App.Tests — architecture/convention rules, pure functional tests (entity methods, validators, specs, mapping), and result-level integration tests against ApiFixture + IMessageBus (handler Result failures, EF persistence, domain events). Use after AppServices actions and endpoint config are ready. Covers only what xUnit owns; HTTP request/response and event-log scenarios belong in the `dknet-bdd-tests` skill. Invoke as `/dknet-unit-tests <Feature> <Entity> [mode=manual\|auto]` to scaffold it for a feature. |
19
20
 
20
21
  ## Reference skills (loaded by the agent when the topic comes up)
21
22
 
@@ -23,14 +24,14 @@ Every folder here is one [Agent Skill](https://agentskills.io) (`<name>/SKILL.md
23
24
  |---|---|
24
25
  | `dknet-auth-and-ownership` | Explains authentication, per-route authorization scopes, acting-user attribution, row-level data ownership, and role-aware sensitive-data filtering in this template — including the demo authentication provider and how to write a test that runs with RequireAuthorization on. Use whenever adding a scope-guarded route, wiring acting-user attribution for a new feature, reasoning about data isolation between callers, or writing an auth-on integration test. |
25
26
  | `dknet-ddd-principles` | DDD tactical judgment for this codebase — aggregate boundaries, entity vs. value object, invariant enforcement, when to use a domain event, avoiding anemic domain models. Use before dknet-entity and dknet-crud whenever the aggregate shape or business-rule placement isn't obvious. |
26
- | `dknet-dto-mapping` | Design response DTOs and Mapster mapping for a DKNet.Minimal feature — hand-written vs [GenerateDto] shapes, custom Mapster IRegister mappings for values the generator's convention can't produce, LazyMapper, and the JSON/sensitive-data contract. Use after (or alongside) dknet-crud when a handler needs a DTO to return. |
27
+ | `dknet-dto-mapping` | Design response DTOs and Mapster mapping for a DKNet.Minimal feature — [GenerateDto] first and hand-written records as the fallback, custom Mapster IRegister mappings for values the generator's convention can't produce, LazyMapper, and the JSON/sensitive-data contract. Use after (or alongside) dknet-crud when a handler needs a DTO to return. |
27
28
  | `dknet-efcore-config` | Create EF Core entity type configurations (mappers), static data seeders, CoreDbContext wiring, and infra domain-service implementations following this project's assembly-scan auto-discovery conventions. Use after creating a domain entity, for both mode=manual and mode=auto entities. |
28
29
  | `dknet-feature-lifecycle` | The add/remove lifecycle of a DKNet vertical-slice business feature — how to choose the manual vs automated flow, the exact file footprint a feature occupies across all six projects, and the out-of-folder touchpoints a delete must clean up. Use before /dknet-feature or /dknet-feature-remove, and whenever you need to enumerate or retire an existing feature. |
29
- | `dknet-messaging-events` | Explains how this template wires SlimMessageBus as its command/query/event backbone, how the two domain-event styles (manual AddEvent vs declared RaisesEvent) reach a subscriber, and how to forward an event to an external Azure Service Bus topic. Use whenever adding a domain event, wiring an internal or external event consumer, or reasoning about how a command/query travels from an endpoint to its handler. |
30
+ | `dknet-messaging-events` | Explains how this template wires SlimMessageBus as its command/query/event backbone, how the three domain-event raise styles ([RaisesEvent], AddEvent<TEvent>(), AddEvent(instance)) reach a subscriber, and how to forward an event to an external Azure Service Bus topic. Use whenever adding a domain event, wiring an internal or external event consumer, or reasoning about how a command/query travels from an endpoint to its handler. |
30
31
  | `dknet-package-adoption` | Add DKNet's Core, EF Core, messaging/CQRS, or blob-storage NuGet packages to an EXISTING .NET project that was NOT created from the DKNet.Minimal.Template. Use when a consumer wants to reuse pieces of the DKNet framework in their own project layout without scaffolding a new solution. |
31
32
  | `dknet-platform-config` | Reference for everything the template wires outside a feature's own vertical slice — Program.cs start-up order, the FeatureManagement flag table, every configuration section and its defaults, launch-time jobs, the Aspire host, and the test-host overrides. Use when adding or changing a platform-level behavior (auth, rate limiting, health checks, telemetry, caching, CORS, a new flag, a new launch job) rather than a business feature. |
32
33
  | `dknet-project-structure` | Orientation to the DKNet.Minimal.Template layer boundaries, the six projects, the vertical-slice folder layout for a feature, and auto-discovery wiring. Use first, before any other dknet-* skill, when working in a solution generated from this template. |
33
- | `dknet-queries-specs` | Write read/query logic for a DKNet feature — Specification<T> filters, hand-written query requests/handlers dispatched over IMessageBus, and the generic filter/search/order/page list route every generator-driven CRUD slice gets for free. Use after the domain entity and DTO exist, whenever a feature needs anything more than the default GET-by-id. |
34
+ | `dknet-queries-specs` | Write read/query logic for a DKNet feature — the generic filter/search/order/page list route every generator-driven CRUD slice gets for free, Specification<T> filters, and hand-written query requests/handlers over IMessageBus for the shapes that route cannot express. Use after the domain entity and DTO exist, whenever a feature needs anything more than the default GET-by-id. |
34
35
  | `dknet-scaffold` | Scaffold a new solution from the DKNet.Minimal template and get it running — dotnet new install/new, the six template parameters, what the generated tree looks like, first build/run with or without Aspire, deleting the two shipped sample features, and installing this skill plugin into the generated repo. Use when starting a new DKNet.Minimal solution, or when orienting inside a freshly generated one. |
35
36
 
36
37
  ## Claude Code subagents (`../agents/`)
@@ -41,7 +42,7 @@ Every folder here is one [Agent Skill](https://agentskills.io) (`<name>/SKILL.md
41
42
 
42
43
  ## Authoring
43
44
 
44
- Rules every skill follows (enforced by `../validate-plugin.sh`): frontmatter `name` equals the folder, a single-line
45
- `description` (max 1024 chars), paths relative to the consumer solution root (`ApiEndpoints/Minimal.*`, never `src/`),
45
+ Rules every skill follows (enforced by `../../validate-plugin.sh`): frontmatter `name` equals the folder, a single-line
46
+ `description` (max 1024 chars), paths relative to the consumer solution root (`ApiEndpoints/<YourApp>.*` where `<YourApp>` is the consumer's solution prefix — never a hard-coded `Minimal.*`, never `src/`),
46
47
  other skills referenced by name, no links into this repository's `docs/`, exemplar code inlined. This index is
47
48
  generated from the frontmatter — regenerate it after changing a skill.
@@ -7,7 +7,7 @@ description: Explains authentication, per-route authorization scopes, acting-use
7
7
 
8
8
  ## `FeatureManagement:RequireAuthorization`
9
9
 
10
- When `true`, `AddAppConfig` calls `AddAuthConfig` (`Minimal.Api/Configs/Auth/AuthConfig.cs`):
10
+ When `true`, `AddAppConfig` calls `AddAuthConfig` (`<YourApp>.Api/Configs/Auth/AuthConfig.cs`):
11
11
 
12
12
  ```csharp
13
13
  services.AddAuthentication().AddJwtBearer();
@@ -47,7 +47,7 @@ local development and the test hosts do not exercise real JWT validation by defa
47
47
 
48
48
  ## Scopes → policies
49
49
 
50
- `Minimal.Api/ApiEndpoints/AutomatedSample/ProductScopes.cs` defines the scope constants for the
50
+ `<YourApp>.Api/ApiEndpoints/AutomatedSample/ProductScopes.cs` defines the scope constants for the
51
51
  `Product` feature:
52
52
 
53
53
  ```csharp
@@ -62,46 +62,96 @@ internal static class ProductScopes
62
62
  ```
63
63
 
64
64
  `AuthConfig`'s `foreach (var scope in ProductScopes.All)` loop registers **one authorization policy
65
- per scope, the scope value doubling as its own policy name** — so a route calls
66
- `.RequireAuthorization(ProductScopes.Read)` directly, no separate policy name to remember.
65
+ per scope, the scope value doubling as its own policy name** — so a route names
66
+ `ProductScopes.Read` directly, with no separate policy name to remember.
67
67
 
68
- A route only calls `RequireAuthorization(scope)` when the flag is actually on — calling it
69
- unconditionally would throw at request time if `RequireAuthorization` is off, because no policies
70
- were registered for the app to resolve against. `ProductV1Endpoint.Map` reads the flag once via DI
71
- and branches on it:
68
+ ### Attach the scope with `[EndpointGroupScope]` first
69
+
70
+ Prefer declaring scopes **above the `IEndpointConfig` class**, not route by route inside `Map`:
72
71
 
73
72
  ```csharp
73
+ [EndpointGroupScope(ProductScopes.Read, EndpointHttpMethods.Get)]
74
+ [EndpointGroupScope(ProductScopes.Write, EndpointHttpMethods.Post, EndpointHttpMethods.Put,
75
+ EndpointHttpMethods.Delete)]
76
+ internal sealed class ProductV1Endpoint : IEndpointConfig
77
+ {
78
+ public int Version => 1;
79
+ public string GroupEndpoint => "/products";
80
+
81
+ public void Map(RouteGroupBuilder group) => group.MapProductCrud();
82
+ }
83
+ ```
84
+
85
+ (`EndpointGroupScopeAttribute`/`EndpointHttpMethods` live in
86
+ `DKNet.AspCore.Extensions.Endpoints`.)
87
+
88
+ Rules, in the order they bite:
89
+
90
+ - The attribute is `AllowMultiple` — stack one declaration per scope, each naming the HTTP methods
91
+ that need it.
92
+ - A declaration naming **no** HTTP method is the group's default for every method it serves. A
93
+ declaration that names a method wins over that default for its own method.
94
+ - It is applied **only when `EndpointRegistrationOptions.RequireAuthorization` is `true`** — the
95
+ same value `Program.cs` assigns from `FeatureOptions.RequireAuthorization`. That is the reason to
96
+ prefer it: the flag check is built in, so there is no `if (!requireAuthorization) return;` branch
97
+ to write and no way to forget one.
98
+ - A route that already names its own policy, or allows anonymous access, is left alone — which is
99
+ what makes the per-route fallbacks below safe to mix in.
100
+ - Once **any** declaration exists on a group, an endpoint serving a method with neither its own
101
+ declaration nor a group default is **refused when the group's endpoints are built**. Cover every
102
+ method the group serves, or declare a default.
103
+ - Requires `DKNet.AspCore.Extensions` **13.0.0 or newer**. The shipped `ProductV1Endpoint` is
104
+ declared exactly this way.
105
+
106
+ ### Fallback, in order
107
+
108
+ Drop one rung only when the rung above cannot express the rule:
109
+
110
+ 1. **`[EndpointGroupScope]`** — the scope is a function of the HTTP method. Covers a whole
111
+ generated CRUD slice in two lines.
112
+ 2. **`o.Configure(CrudOp.X, …)` / `o.Configure("RouteName", …)`** — two routes sharing one HTTP
113
+ method need *different* scopes, so no per-method declaration can separate them.
114
+ `o.Configure(CrudOp, …)` targets a generated composite route by operation kind (see
115
+ `dknet-endpoint`); `o.Configure("RouteName", …)` targets one `[CrudAction]` route by its C#
116
+ member name. `Product` needs this: `Update` and `AssignSupplierReference` are both `PUT`, and
117
+ the second must demand `products.supplier` rather than `products.write`.
118
+ 3. **`.RequireAuthorization(scope)` on the `RouteHandlerBuilder`** — a hand-mapped route, or a
119
+ generated route replaced by a hand-mapped one (`discontinue` below).
120
+
121
+ Rungs 2 and 3 do **not** self-gate. Calling `RequireAuthorization(scope)` with the flag off throws
122
+ at request time, because `AddAuthConfig` never ran and no policy of that name was registered — so
123
+ each must sit behind a flag check. `ProductV1Endpoint` reads it once via DI, and needs it for
124
+ exactly the two `PUT` routes the group declarations cannot separate:
125
+
126
+ ```csharp
127
+ // Only for the per-route overrides below — the class-level declarations gate themselves.
74
128
  var requireAuthorization = ((IEndpointRouteBuilder)group).ServiceProvider
75
129
  .GetRequiredService<IOptions<FeatureOptions>>().Value.RequireAuthorization;
76
130
 
77
131
  group.MapProductCrud(o =>
78
132
  {
79
133
  o.Exclude("Discontinue");
80
- if (!requireAuthorization) return;
81
-
82
- o.Configure(CrudOp.GetById, rb => rb.RequireAuthorization(ProductScopes.Read));
83
- o.Configure(CrudOp.GetList, rb => rb.RequireAuthorization(ProductScopes.Read));
84
- o.Configure(CrudOp.Create, rb => rb.RequireAuthorization(ProductScopes.Write));
85
- o.Configure(CrudOp.Update, rb => rb.RequireAuthorization(ProductScopes.Write));
86
- o.Configure(CrudOp.Delete, rb => rb.RequireAuthorization(ProductScopes.Write));
87
- o.Configure("Approve", rb => rb.RequireAuthorization(ProductScopes.Write));
88
- // Its own scope, not Write — holding only products.write must not be enough to assign it.
89
- o.Configure("AssignSupplierReference", rb => rb.RequireAuthorization(ProductScopes.Supplier));
134
+
135
+ // A PUT like Update, so no per-method declaration separates them, and products.write must not
136
+ // be enough to assign a supplier reference.
137
+ if (requireAuthorization)
138
+ o.Configure("AssignSupplierReference", rb => rb.RequireAuthorization(ProductScopes.Supplier));
90
139
  });
91
140
 
92
141
  var discontinue = group.MapPut("{id:guid}/discontinue", /* ... */);
93
142
  if (requireAuthorization) discontinue.RequireAuthorization(ProductScopes.Discontinue);
143
+
144
+ // GET summary needs no call at all — the group's products.read declaration covers it.
145
+ group.MapGet("summary", /* ... */);
94
146
  ```
95
147
 
96
- `o.Configure(CrudOp, ...)` targets a generated composite route by operation kind (see
97
- `dknet-endpoint`); `o.Configure("RouteName", ...)` targets a specific `[CrudAction]` route by
98
- its C# member name. A hand-mapped route (`discontinue` above) calls `.RequireAuthorization(scope)`
99
- directly on the `RouteHandlerBuilder` the same way.
148
+ `GetById`, `GetList`, `Create`, `Update`, `Delete` and `Approve` carry no scope call: their HTTP
149
+ method decides their scope, and the two class-level declarations already say what it is.
100
150
 
101
151
  `IEndpointConfig` also exposes an optional `string? AuthPolicy` member for gating an entire group
102
- under one policy (`null` means plain authentication) rather than per-route scopes — neither shipped
103
- sample overrides it; both use per-route `RequireAuthorization(scope)` inside `Map` instead, because
104
- different routes in the same group need different scopes.
152
+ under one policy (`null` means plain authentication) rather than per-method scopes — neither shipped
153
+ sample overrides it, because different methods in the same group need different scopes and
154
+ `[EndpointGroupScope]` expresses that without giving up the group-level declaration.
105
155
 
106
156
  **Recipe: add a new scope for a new feature.**
107
157
  1. Add a constant (and to an `All` array, if you loop like `ProductScopes` does) in a
@@ -109,14 +159,15 @@ different routes in the same group need different scopes.
109
159
  2. Register it as a policy — either loop over your `All` array in `AuthConfig` the way
110
160
  `ProductScopes.All` is registered, or call `options.AddPolicy(YourScopes.X, ...)` explicitly for a
111
161
  one-off scope.
112
- 3. Call `.RequireAuthorization(YourScopes.X)` on the route, gated behind the same
113
- `FeatureOptions.RequireAuthorization` check `ProductV1Endpoint` uses — never call it
114
- unconditionally.
162
+ 3. Attach it with `[EndpointGroupScope(YourScopes.X, EndpointHttpMethods.…)]` on the endpoint class.
163
+ Only where a single HTTP method needs two different scopes, fall back to `o.Configure(...)` or
164
+ `.RequireAuthorization(...)` — and then gate that call behind
165
+ `FeatureOptions.RequireAuthorization`, never call it unconditionally.
115
166
 
116
167
  ## Demo authentication
117
168
 
118
169
  `FeatureManagement:EnableDemoAuthentication` registers `DemoAuthenticationHandler`
119
- (`Minimal.Api/Configs/Auth/DemoAuthConfig.cs`) as the default authenticate/challenge scheme. Every
170
+ (`<YourApp>.Api/Configs/Auth/DemoAuthConfig.cs`) as the default authenticate/challenge scheme. Every
120
171
  request is authenticated, unconditionally, as a fixed fake identity:
121
172
 
122
173
  ```csharp
@@ -156,10 +207,19 @@ if (string.IsNullOrEmpty(request.ByUser))
156
207
  — from `CreatePurchaseOrderCommandHandler`. A payload value for `ByUser` is always overwritten, never
157
208
  trusted; this is a security seam, not a binding convenience.
158
209
 
210
+ `[FromRequestHeader("X-Header-Name")]` is the same populator reading a named request header instead
211
+ of a claim: also overwritten before validation and the handler (with the property's default when the
212
+ header is absent), also impossible to forge through the payload, and also inert unless
213
+ `AddContextualRequestPopulation` is registered — it is. A missing header is never a refusal, just a
214
+ default. **It is not an authorization signal**: a header is caller-supplied and carries no identity
215
+ guarantee, so never use it where `[FromClaim]` belongs. Neither shipped sample uses it; reach for it
216
+ for correlation ids, a tenant hint, or a client-version marker the handler wants without adding a
217
+ body field.
218
+
159
219
  **Automated: `PrincipalProvider` + two save hooks.** A `[CrudCreate]`/`[CrudUpdate]`/`[CrudAction]`
160
220
  generated request forwards only `System.ComponentModel.DataAnnotations` attributes, so it can never
161
221
  carry `[FromClaim]`. Acting-user attribution instead goes through `PrincipalProvider`
162
- (`Minimal.Api/Configs/Handlers/PrincipalProvider.cs`), which implements `IPrincipalProvider`
222
+ (`<YourApp>.Api/Configs/Handlers/PrincipalProvider.cs`), which implements `IPrincipalProvider`
163
223
  (`IDataOwnerProvider` + `ICurrentUserProvider` plus `ProfileId`/`Email`/`UserName`):
164
224
 
165
225
  ```csharp
@@ -257,7 +317,7 @@ pins the whole chain: an authenticated caller with no resolvable subject claim g
257
317
  is never persisted at all (checked with `IgnoreQueryFilters()` directly against the database, not
258
318
  just "unreadable over HTTP").
259
319
 
260
- **Tests that pin this area** (all in `Minimal.App.Tests/Integration/...`):
320
+ **Tests that pin this area** (all in `<YourApp>.App.Tests/Integration/...`):
261
321
 
262
322
  - `AutomatedSample/V1/ProductOwnershipIsolationTests` — two distinct authenticated callers get
263
323
  distinct ownership keys; neither can read or list the other's row; `oid` takes precedence over
@@ -325,7 +385,7 @@ that two callers are judged independently in either request order), `ProductSens
325
385
  POST routes are not idempotent unless the route calls `.RequiredIdempotentKey()` explicitly, with
326
386
  callers sending `X-Idempotency-Key`. The store is Redis when `ConnectionStrings:Redis` is set, else
327
387
  an in-process in-memory store; both use `ConflictHandling = IdempotentConflictHandling.ConflictResponse`
328
- (`Minimal.Api/Configs/AppConfig.cs`). See `dknet-crud` for how a handler's `Result`
388
+ (`<YourApp>.Api/Configs/AppConfig.cs`). See `dknet-crud` for how a handler's `Result`
329
389
  maps to a response body, and `dknet-platform-config` for CORS, security headers, and rate limiting.
330
390
  Status mapping: 400 validation, 401 no/invalid credential, 403 `OwnershipRequiredException` or a
331
391
  failed authorization policy, 404 not found or filtered out by ownership, 409 a
@@ -365,7 +425,7 @@ public sealed class AuthOnApiFixture : TestApiFactoryBase, IAsyncLifetime
365
425
  This is only safe because the test assembly disables collection parallelization — no other test's
366
426
  host can boot while the variable is set, or it would leak into an unrelated test run.
367
427
 
368
- `TestAuthHandler` (`Minimal.App.TestSupport/TestAuthHandler.cs`) replaces the real JWT bearer scheme
428
+ `TestAuthHandler` (`<YourApp>.App.TestSupport/TestAuthHandler.cs`) replaces the real JWT bearer scheme
369
429
  so a request can be authenticated without a live token. It issues a fixed name/subject and a scope
370
430
  claim built from every `ProductScopes` entry by default, overridable per request via a header:
371
431
 
@@ -387,7 +447,7 @@ in the constructor, cleared in `Dispose`, `TestAuthHandler.Register(services)` i
387
447
  `ConfigureTestServices`), then assert against `TestAuthHandler.CallerName` /
388
448
  `TestAuthHandler.CallerProfileId` the same way `PurchaseOrderSecurityTests`/`ProductSecurityTests`
389
449
  do. For a test that needs two distinct callers in one host (row-isolation tests),
390
- `Minimal.App.TestSupport/MultiSubjectAuthHandler.cs` reads the subject from a request header instead
450
+ `<YourApp>.App.TestSupport/MultiSubjectAuthHandler.cs` reads the subject from a request header instead
391
451
  of a fixed constant — see `AuthOnMultiSubjectApiFixture` and `ProductOwnershipIsolationTests`. For a
392
452
  caller authenticated with no name claim at all, see `AuthOnNoNameClaimApiFixture`. For a fixed-tenant
393
453
  `OwnedBy` with a still-distinct acting `CreatedBy`, see `AuthOnFixedTenantApiFixture`.
@@ -399,8 +459,12 @@ the thing under test, and it is expected to fail start-up.
399
459
 
400
460
  - **What you might expect:** calling `.RequireAuthorization(scope)` unconditionally on a route.
401
461
  **What actually happens:** with `RequireAuthorization` off, no policies were ever registered, so
402
- the call throws at request time. Always gate it on `FeatureOptions.RequireAuthorization`, as
403
- `ProductV1Endpoint` does.
462
+ the call throws at request time. Gate it on `FeatureOptions.RequireAuthorization`, as
463
+ `ProductV1Endpoint` does — or declare the scope with `[EndpointGroupScope]`, which is applied only
464
+ when that flag is on and therefore needs no branch at all.
465
+ - **What you might expect:** one `[EndpointGroupScope]` covering the methods you care about leaves
466
+ the rest of the group unguarded. **What actually happens:** it fails closed — a served method with
467
+ no declaration and no group default makes endpoint building throw, not silently pass through.
404
468
  - **What you might expect:** giving a generated `[CrudAction]` a parameter meant to auto-populate
405
469
  from the caller. **What actually happens:** it becomes an ordinary, caller-settable bound property
406
470
  — there is no `[FromClaim]` seam on a generated request.